?

Using Testcontainers for Unit Tests With Spring & Kotlin

Many applications built upon the Spring framework make use of JPA and relational databases. This brings about considerations in unit testing these additional dependencies.

October 26, 2020 7 minute read

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.

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.

Learn more about our software development expertise.
Share this

Comments