Spring Boot JPA Testing
Credits: Tom Hombergs / https://reflectoring.io/spring-boot-data-jpa-test/
Aside from unit tests, integration tests play a vital role in producing quality software. A special kind of integration test deals with the integration between our code and the database.
With the @DataJpaTest
annotation, Spring Boot provides a convenient way to set up an environment with an embedded database to test our database queries against.
In this tutorial, we’ll first discuss which types of queries are worthy of tests and then discuss different ways of creating a database schema and database state to test against.
Example Code
This article is accompanied by a working code example on GitHub.
The “Testing with Spring Boot” Series
This tutorial is part of a series:
- Unit Testing with Spring Boot
- Testing Spring MVC Web Controllers with
@WebMvcTest
- Testing JPA Queries with Spring Boot and
@DataJpaTest
- Integration Tests with
@SpringBootTest
If you like learning from videos, make sure to check out Philip’s Testing Spring Boot Applications Masterclass (if you buy through this link, I get a cut).
Dependencies
In this tutorial, aside from the usual Spring Boot dependencies, we’re using JUnit Jupiter as our testing framework and H2 as an in-memory database.
dependencies { compile('org.springframework.boot:spring-boot-starter-data-jpa') compile('org.springframework.boot:spring-boot-starter-web') runtime('com.h2database:h2') testCompile('org.springframework.boot:spring-boot-starter-test') testCompile('org.junit.jupiter:junit-jupiter-engine:5.2.0') }
What to Test?
The first question to answer to ourselves is what we need to test. Let’s consider a Spring Data repository responsible for UserEntity
objects:
interface UserRepository extends CrudRepository<UserEntity, Long> { // query methods }
We have different options to create queries. Let’s look at some of those in detail to determine if we should cover them with tests.
Inferred Queries
The first option is to create an inferred query:
UserEntity findByName(String name);
We don’t need to tell Spring Data what to do, since it automatically infers the SQL query from the name of the method name.
What’s nice about this feature is that Spring Data also automatically checks if the query is valid at startup. If we renamed the method to findByFoo()
and the UserEntity
does not have a property foo
, Spring Data will point that out to us with an exception:
org.springframework.data.mapping.PropertyReferenceException: No property foo found for type UserEntity!
So, as long as we have at least one test that tries to start up the Spring application context in our code base, we do not need to write an extra test for our inferred query.
Note that this is not true for queries inferred from long method names like findByNameAndRegistrationDateBeforeAndEmailIsNotNull()
. This method name is hard to grasp and easy to get wrong, so we should test if it really does what we intended.
Having said this, it’s good practice to rename such methods to a shorter, more meaningful name and add a @Query
annotation to provide a custom JPQL query.
Custom JPQL Queries with @Query
If queries become more complex, it makes sense to provide a custom JPQL query:
@Query("select u from UserEntity u where u.name = :name") UserEntity findByNameCustomQuery(@Param("name") String name);
Similar to inferred queries, we get a validity check for those JPQL queries for free. Using Hibernate as our JPA provider, we’ll get a QuerySyntaxException
on startup if it found an invalid query:
org.hibernate.hql.internal.ast.QuerySyntaxException: unexpected token: foo near line 1, column 64 [select u from ...]
Custom queries, however, can get a lot more complicated than finding an entry by a single attribute. They might include joins with other tables or return complex DTOs instead of an entity, for instance.
So, should we write tests for custom queries? The unsatisfying answer is that we have to decide for ourselves if the query is complex enough to require a test.
Native Queries with @Query
Another way is to use a native query:
@Query( value = "select * from user as u where u.name = :name", nativeQuery = true) UserEntity findByNameNativeQuery(@Param("name") String name);
Instead of specifying a JPQL query, which is an abstraction over SQL, we’re specifying an SQL query directly. This query may use a database-specific SQL dialect.
It’s important to note that neither Hibernate nor Spring Data validate native queries at startup. Since the query may contain database-specific SQL, there’s no way Spring Data or Hibernate can know what to check for.
So, native queries are prime candidates for integration tests. However, if they really use database-specific SQL, those tests might not work with the embedded in-memory database, so we would have to provide a real database in the background (for instance in a docker container that is set up on-demand in the continuous integration pipeline).
@DataJpaTest
in a Nutshell
To test Spring Data JPA repositories, or any other JPA-related components for that matter, Spring Boot provides the @DataJpaTest
annotation. We can just add it to our unit test and it will set up a Spring application context:
@ExtendWith(SpringExtension.class) @DataJpaTest class UserEntityRepositoryTest { @Autowired private DataSource dataSource; @Autowired private JdbcTemplate jdbcTemplate; @Autowired private EntityManager entityManager; @Autowired private UserRepository userRepository; @Test void injectedComponentsAreNotNull(){ assertThat(dataSource).isNotNull(); assertThat(jdbcTemplate).isNotNull(); assertThat(entityManager).isNotNull(); assertThat(userRepository).isNotNull(); } }
@ExtendWith
The code examples in this tutorial use the @ExtendWith
annotation to tell JUnit 5 to enable Spring support. As of Spring Boot 2.1, we no longer need to load the SpringExtension
because it’s included as a meta annotation in the Spring Boot test annotations like @DataJpaTest
, @WebMvcTest
, and @SpringBootTest
.
The so created application context will not contain the whole context needed for our Spring Boot application, but instead only a “slice” of it containing the components needed to initialize any JPA-related components like our Spring Data repository.
We can, for instance, inject a DataSource
, @JdbcTemplate
or @EntityManager
into our test class if we need them. Also, we can inject any of the Spring Data repositories from our application. All of the above components will be automatically configured to point to an embedded, in-memory database instead of the “real” database we might have configured in application.properties
or application.yml
files.
Note that by default the application context containing all these components, including the in-memory database, is shared between all test methods within all @DataJpaTest
-annotated test classes.
This is why, by default, each test method runs in its own transaction, which is rolled back after the method has executed. This way, the database state stays pristine between tests and the tests stay independent of each other.
Creating the Database Schema
Before we can test any queries to the database, we need to create an SQL schema to work with. Let’s look at some different ways to do this.
Using Hibernate’s ddl-auto
By default, @DataJpaTest
will configure Hibernate to create the database schema for us automatically. The property responsible for this is spring.jpa.hibernate.ddl-auto
, which Spring Boot sets to create-drop
by default, meaning that the schema is created before running the tests and dropped after the tests have executed.
So, if we’re happy with Hibernate creating the schema for us, we don’t have to do anything.
Using schema.sql
Spring Boot supports executing a custom schema.sql
file when the application starts up.
If Spring finds a schema.sql
file in the classpath, this will be executed against the datasource. This overrides the ddl-auto
configuration of Hibernate discussed above.
We can control whether the schema.sql
file should be executed with the property spring.datasource.initialization-mode
. The default value is embedded
, meaning it will only execute for an embedded database (i.e. in our tests). If we set it to always
, it will always execute.
The following log output confirms that the file has been executed:
Executing SQL script from URL [file:.../out/production/resources/schema.sql]
It makes sense to set Hibernate’s ddl-auto
configuration to validate
when using a script to initialize the schema, so that Hibernate checks if the created schema matches the entity classes on startup:
@ExtendWith(SpringExtension.class) @DataJpaTest @TestPropertySource(properties = { "spring.jpa.hibernate.ddl-auto=validate" }) class SchemaSqlTest { ... }
Using Flyway
Flyway is a database migration tool that allows to specify multiple SQL scripts to create a database schema. It keeps track of which of these scripts have already been executed on the target database, so that it executes only those that have not been executed before.
To activate Flyway, we just need to drop the dependency into our build.gradle
file (similar if we’d use Maven):
compile('org.flywaydb:flyway-core')
Hibernate’s ddl-auto
configuration will automatically back off if we have not specifically configured it, so that Flyway has precedence and will by default execute all SQL scripts it finds in the folder src/main/resources/db/migration
against our in-memory test database.
Again, it makes sense to set ddl-auto
to validate
, to let Hibernate check if the schema generated by Flyway matches the expectations of our Hibernate entities:
@ExtendWith(SpringExtension.class) @DataJpaTest @TestPropertySource(properties = { "spring.jpa.hibernate.ddl-auto=validate" }) class FlywayTest { ... }
The Value of using Flyway in Tests
If we’re using Flyway in production it’s really nice if we can also use it in our JPA tests as described above. Only then do we know at test time that the flyway scripts work as expected.
This only works, however, as long as the scripts contain SQL that is valid on both the production database and the in-memory database used in the tests (an H2 database in our example). If this is not the case, we must disable Flyway in our tests by setting the spring.flyway.enabled
property to false
and the spring.jpa.hibernate.ddl-auto
property to create-drop
to let Hibernate generate the schema.
In any case, let’s make sure to set the ddl-auto
property to validate
in the production profile! It’s our last line of defense against errors in our Flyway scripts!
Using Liquibase
Liquibase is another database migration tool that works similar to Flyway but supports other input formats besides SQL. We can provide YAML or XML files, for example, that define the database schema.
We activate it by simply adding the dependency:
compile('org.liquibase:liquibase-core')
Liquibase will then automatically create the schema defined in src/main/resources/db/changelog/db.changelog-master.yaml
by default.
Yet again, it makes sense to set ddl-auto
to validate
:
@ExtendWith(SpringExtension.class) @DataJpaTest @TestPropertySource(properties = { "spring.jpa.hibernate.ddl-auto=validate" }) class LiquibaseTest { ... }
The Value of using Liquibase in Tests
As Liquibase allows multiple input formats that act as an abstraction layer over SQL, the same scripts can be used across multiple databases, even if their SQL dialects differ. This makes it possible to use the same Liquibase scripts in our tests and in production.
The YAML format is very sensitive, though, and I recently had trouble maintaining a collection of big YAML files. This, and the fact that in spite of the abstraction we actually had to edit those files for different databases, ultimately led to a switch to Flyway.
Populating the Database
Now that we have created a database schema for our tests, we can finally start the actual testing. In database query tests, we usually add some data to the database and then validate if our queries return the correct results.
Again, there are multiple ways of adding data to our in-memory database, so let’s discuss each of them.
Using data.sql
Similar to schema.sql
, we can use a data.sql
file containing insert statements to populate our database. The same rules apply as above.
Maintainability
A data.sql
file forces us to put all our insert
statements into a single place. Every single test will depend on this one script to set up the database state. This script will soon become very large and hard to maintain. And what if there are tests that require conflicting database states?
This approach should therefore be considered with caution.
Inserting Entities Manually
The easiest way to create a specific database state per test is to just save some entities in the test before running the query under test:
@Test void whenSaved_thenFindsByName() { userRepository.save(new UserEntity( "Zaphod Beeblebrox", "zaphod@galaxy.net")); assertThat(userRepository.findByName("Zaphod Beeblebrox")).isNotNull(); }
This is easy for simple entities like in the example above. But in real projects those entities usually are a lot more complex to build and have relationships to other entities. Also, if we want to test a more complex query than findByName
, chances are that we need to create more data than a single entity. This quickly becomes very tiresome.
One way to tame this complexity is to create factory methods, perhaps in combination with the Objectmother and Builder patterns.
The approach of “manually” programming the database population in Java code has a big advantage over the other approaches in that it’s refactoring-safe. Changes in the codebase lead to compile errors in our test code. In all other approaches, we have to run the tests to be notified about potential errors due to a refactoring.
Using Spring DBUnit
DBUnit is a library that supports setting databases into a certain state. Spring DBUnit integrates DBUnit with Spring so that it automatically works with Spring’s transactions, among other things.
To use it, we need to add the dependencies to Spring DBUnit and DBUnit:
compile('com.github.springtestdbunit:spring-test-dbunit:1.3.0') compile('org.dbunit:dbunit:2.6.0')
Then, for each test we can create a custom XML file containing the desired database state:
<?xml version="1.0" encoding="UTF-8"?> <dataset> <user id="1" name="Zaphod Beeblebrox" email="zaphod@galaxy.net" /> </dataset>
By default, the XML file (let’s name it createUser.xml
) lie in the classpath next to the test class.
In the test class, we need to add two TestExecutionListeners
to enable DBUnit support. To set a certain database state we can then use @DatabaseSetup
on a test method:
@ExtendWith(SpringExtension.class) @DataJpaTest @TestExecutionListeners({ DependencyInjectionTestExecutionListener.class, TransactionDbUnitTestExecutionListener.class }) class SpringDbUnitTest { @Autowired private UserRepository userRepository; @Test @DatabaseSetup("createUser.xml") void whenInitializedByDbUnit_thenFindsByName() { UserEntity user = userRepository.findByName("Zaphod Beeblebrox"); assertThat(user).isNotNull(); } }
For testing queries that change the database state we could even use @ExpectedDatabase
to define the state the database is expected to be in after the test.
Note, however, that Spring DBUnit has not been maintained since 2016.
@DatabaseSetup
not working?
In my tests I had the problem that the @DatabaseSetup
annotation was silently ignored. Turned out there was a ClassNotFoundException
as some DBUnit class could not be found. This exception was swallowed, though.
The reason was that I forgot to include the dependency to DBUnit, since I thought that Spring Test DBUnit included it transitively. So, if you have the same problem, check if you have included both dependencies.
Using @Sql
A very similar approach is using Spring’s @Sql
annotation. Instead of using XML to describe the database state, we’re using SQL directly:
-- createUser.sql INSERT INTO USER (id, NAME, email) VALUES (1, 'Zaphod Beeblebrox', 'zaphod@galaxy.net');
In our test, we can simply use the @Sql
annotation to refer to the SQL file to populate the database:
@ExtendWith(SpringExtension.class) @DataJpaTest class SqlTest { @Autowired private UserRepository userRepository; @Test @Sql("createUser.sql") void whenInitializedByDbUnit_thenFindsByName() { UserEntity user = userRepository.findByName("Zaphod Beeblebrox"); assertThat(user).isNotNull(); } }
If we need more than one script, we can use @SqlGroup
to combine them.