Using Testcontainers for Unit Tests With Spring & Kotlin
While in-memory databases such as H2 afford some conveniences, they can often bring about complications when utilizing related tools such as database migration tools. Being able to use the same database that exists in the deployed environments during automated testing increases maintainability and confidence.
Please see the Github repository for this article to follow along.
Initial setup
Making use of the Spring Initializr site will give us a template project to start with. For the purposes of this article, we'll select a project type of Gradle Project, a language of Kotlin, and add in the Spring Data JPA dependency.
Once this is done and the downloaded file unzipped the project can be compiled and tests can be run. Before moving on to the first test a few more dependencies are needed to get us going. To the build.gradle.kts
file add in the following lines:
First, an extra property storing the Testcontainers version we'll be using.
extra["testcontainersVersion"] = "1.15.0"
Next, we'll add in some dependencies needed for the unit tests
runtimeOnly("org.postgresql:postgresql:42.2.18")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
The postgresql
library will allow the code to make the Postgres specific JDBC calls at runtime. This library is not needed at compile time because the code will employ abstractions built upon JDBC. The testcontainers
and related libraries will bring in the implementation needed to run a Postgres container while the unit tests are running.
Lastly, we'll make use of the Testcontainers bill of materials (BOM) to help manage the related artifacts with the version property we defined above.
dependencyManagement {
imports {
mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
}
}
Unit test configuration
If we were to attempt running the provided test class of DemoApplicationTests
at this point without any updates, we would see exceptions with phrases such as "Failed to load ApplicationContext" or "Failed to determine a suitable driver class" due to missing configuration values and a running database. So let's resolve those.
To the existing DemoApplicationTests
we'll add the following.
First a couple of type annotations to the class:
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
and
@Testcontainers
.
Next, we'll add the code below to the class itself.
companion object {
@Container
private val postgreSQLContainer = PostgreSQLContainer<Nothing>("postgres:latest")
@DynamicPropertySource
@JvmStatic
fun registerDynamicProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", postgreSQLContainer::getPassword)
}
}
At this point the DemoApplicationTests
suite is loading the application contexts and has the ability to access a Postgres database running within a Docker container. The properties needed to access the database, the URL, username and password are being set dynamically prior to the unit tests running.
For reference, the full DemoApplicationTests
class should look like the following code sample.
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class DemoApplicationTests {
companion object {
@Container
private val postgreSQLContainer = PostgreSQLContainer<Nothing>("postgres:latest")
@DynamicPropertySource
@JvmStatic
fun registerDynamicProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", postgreSQLContainer::getPassword)
}
}
@Test
fun contextLoads() {
}
}
First database test
Prior to writing our first unit test to verify we can interact with the running database let's add in an additional dependency that we'll utilize while compiling and running the unit tests.
testImplementation("io.kotest:kotest-assertions-core-jvm:4.3.1")
This library will take advantage of Kotlin's syntax to give us some assertion functions that are infixed and some additional matchers for easier to read code.
Now we can write a test to verify the version of the Postgres instance running within the container. First we'll add an injected value for a jdbcTemplate
to allow for queries to be run against the database.
@Autowired
private lateinit var jdbcTemplate: JdbcTemplate
Next, we'll write a test that queries the database for its version string and verify version 13 of Postgres is running. The test is structured with the "Arrange/Act/Assert" pattern. The actual
variable will store the strings returned from the query.
In the action step, the actualDatabaseVersion
variable will store the string returned from the query. The queryForObject
method will throw an IncorrectResultSizeDataAccessException
if the query returns more than one result. In the assert step, we verify the result is a string containing the value "Postgres 13.0" which is the latest version of Postgres at the time this article was written.
@Test
fun `when database is connected then it should be Postgres version 13`() {
val actualDatabaseVersion = jdbcTemplate.queryForObject("SELECT version()", String::class.java)
actualDatabaseVersion shouldContain "PostgreSQL 13.0"
}
With our first test written, we've verified the ability to query a running database while the tests are invoked. Next, we'll look at how to unit test Spring repositories.
Repositories and entities
First, we'll create a Person
entity representing a single row within our database. The @Id
annotation will indicate that the id value and mapped column is the primary key. The @GeneratedValue
annotation indicates the value is generated by the database. This example takes advantage of Koltin's data classes, but it's important to understand the tradeoffs when using them.
While this does allow us to avoid having to write some boilerplate code, it doesn't come without disadvantages. It's an exercise for the reader to investigate and consider the pros and cons.
@Entity
data class Person(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val name: String,
val preferredName: String
)
Next, we'll create a simple repository with a single additional function to retrieve person records by the preferred name.
@Repository
interface PersonRepository: JpaRepository<Person, Long> {
fun findByPreferredName(preferredName: String): List<Person>
}
With the repository and entity written, let's take a look at what the PersonRepositoryTests
suite looks like.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class PersonRepositoryTests {
companion object {
@Container
private val postgreSQLContainer = PostgreSQLContainer<Nothing>("postgres:latest")
@DynamicPropertySource
@JvmStatic
fun registerDynamicProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl)
registry.add("spring.datasource.username", postgreSQLContainer::getUsername)
registry.add("spring.datasource.password", postgreSQLContainer::getPassword)
}
}
@Autowired
private lateinit var testObject: PersonRepository
@Test
fun `when record is saved then the id is populated`() {
val actual = testObject.save(Person(name = "Tom Smith", preferredName = "Tom"))
actual.id shouldBeInRange (1..Long.MAX_VALUE)
}
@Test
fun `when multiple records with the same preferred name then all are found`() {
testObject.save(Person(Random.nextLong(), "Tom Smith", "Tom"))
testObject.save(Person(Random.nextLong(), "Mark Smith", "Tom"))
testObject.save(Person(Random.nextLong(), "Thomas Doe", "Tom"))
testObject.save(Person(Random.nextLong(), "Tommy Jones", "Tom"))
val actual = testObject.findByPreferredName("Tom")
actual shouldHaveSize 4
}
}
We have a similar setup to the DemoApplicationTests
above. We'll replace the @SpringBootTest
annotation with @DataJpaTest
because this suite of tests doesn't require the full application configured. By default, tests annotated with @DataJpaTest
are transactional and roll back at the end of each test.
At this point, while running the tests, an error occurs due to the database missing a relation named "person." So we'll have to create that. One method for this is to use a migration library such as Flyway. Let's take a look at how to do this.
Flyway for database migration
One additional dependency will be needed for Spring to become aware that migration files need to be run at startup. To the build.gradle.kts
file, add runtimeOnly("org.flywaydb:flyway-core:7.2.0")
.
Let's create a file at main/kotlin/resources/db/migration/V2020.10.13.1__create_person_relation.sql
to store the SQL to create a new table named person
. The SQL below will create a table with the three columns needed to match the entity we created above.
CREATE TABLE person(
id SERIAL NOT NULL UNIQUE,
name VARCHAR (255) NOT NULL,
preferred_name VARCHAR (255) NOT NULL,
PRIMARY KEY (id)
);
The SQL file can be written using Postgres specific syntax because the tests will run against an actual Postgres instance. Running our tests now shows the test passing. The console output will display some information about the running of the migration file during startup.
Note on no-arg compiler plugin
Within the build.gradle.kts
file, you will notice the kotlin("plugin.jpa")
entry within the plugins section. The no-arg compiler plugin generates zero-argument constructors needed for the Java Persistence API to instantiate classes such as our entity class.
The Github repository contains a working example to reference.