How to test your SQL with Testcontainers and Flyway

In this post, you will learn

  • Why mistakes happen when integrating with databases

  • What database integration tests are, and

  • How to set up database integration tests

This post will assume you have some basic knowledge of Kotlin and Postgres.

Why mistakes happen when integrating with databases

When writing SQL code, usually the way to test that the code works as expected is through manual testing.

One way to test the code is to release it to a development environment with existing data, and then, call the application endpoints to see if the code works as expected. In some cases, you may have to insert some data to test different scenarios.

Another way is to test it locally. For example, in order to test a query works, the following steps can be done:

  1. Run a Postgres instance locally

  2. Run migration scripts to setup your schemas and tables

  3. Run your application

  4. Insert some test data

  5. Call your application endpoints and observe the results are as expected

The issue with manual testing (whether it be in a development environment or locally) is that it is slow and tedious. Often times, if a team does not have an easy way to perform these tests, then only a subset of tests are done, or even worse, testing is not done at all. Because of the slow and tedious nature, reviewers of PRs will also tend to approve without testing either. This means bugs usually sneak through in local and development environments, and are only found in test or even production environments.

Let's look at an example scenario where your team does manual testing.

Example Scenario: Batch inserting users

Scenario Your application currently has a method that inserts a single user to the database. This code is already in production and it works fine. However, there is now some requirements that it needs to support batch inserting users due to slow performance.

Let's have a look at the method:

src/main/kotlin/PostgresRepository.kt

fun insertUser(dataSource: DataSource, user: User) {  
    dataSource.connection.use { connection ->  
        connection.prepareStatement(INSERT_USER_SQL).use { statement ->  
            statement.setString(1, user.id)  
            statement.setString(2, user.name)  
            statement.execute()  
        }  
    }  
}  

const val INSERT_USER_SQL = """  
 INSERT INTO users
 (id, name)
 VALUES (?, ?)
"""  

data class User(val id: String, val name: String)

One of the junior engineers in your team decide to raise the following PR to implement this feature. The junior mentioned that he has done some manual testing and it works as expected.

src/main/kotlin/PostgresRepository.kt

fun insertUsers(dataSource: DataSource, users: List<User>) {  
    dataSource.connection.use { connection ->  
        connection.prepareStatement(INSERT_USER_SQL).use { statement ->  
            for (user in users) {  
                statement.setString(1, user.id)  
                statement.setString(2, user.name)  
                statement.addBatch()  
            }
            statement.execute()  
        }  
    }  
}

As the most senior person in the team, do you approve the PR? Are you confident that the code works as intended?

Because of the lack of any automated tests, the only way to ensure the code works is to manually test it locally or in a development environment. This seems a little inconvenient, especially since you are busy with another project and do not have time to this manual testing. So what do you do?

Thankfully, integration tests can help here.

What are integration tests?

Integration tests allow us to test code that integrates with external systems. This could be testing code that uses the JDBC library which integrates with a Postgres database. Another example is testing code that uses a Ktor HTTP client which integrates with a HTTP server.

Integration tests are not necessarily end-to-end tests. An end-to-end test will run a full application and test the end-to-end functionality - ie. from receiving a request, validating it, updating a database table and responding to the request. An integration test on the other hand may only test against a part of the application such as database access.

In the previous example, if database integration tests were added as part of your CI/CD process, then a passing integration test will give you confidence that the code works as intended. Let's look at how we can set them up.

Setting up database integration tests

In order to set up database integration tests, you will need

  • Testcontainers. This allows us to spin up a Postgres container inside Docker.

  • Flyway. This allows us to run migration scripts to setup schemas and tables in our Postgres container

Using the previous example, let's go through a step-by-step guide on how to can set up integration tests for the batch insert method.

1. Setup Testcontainers

Firstly, add the Testcontainers PostgreSQL dependency to the build.gradle.kts file.

dependencies {  
    testImplementation("org.testcontainers:postgresql:1.17.1")  
}

Then, setup a TestDatabase.kt file in your test source set (ie. src/test/kotlin/util/TestDatabase.kt).

object TestDatabase {  
    private val container = PostgreSQLContainer(DockerImageName.parse("postgres:13.4"))  

    fun setup(): DataSource {  
        if (!container.isRunning) {  
            container.start()  
        }  

        return setupDataSource(container)
    }

    private fun setupDataSource(postgreSQLContainer: PostgreSQLContainer<*>): DataSource {  
        return PGSimpleDataSource().apply {  
            setURL(postgreSQLContainer.jdbcUrl)  
            user = postgreSQLContainer.username  
            password = postgreSQLContainer.password  
        }  
    }
}

The setup method in this file will spin up a Postgres docker container via Testcontainers and also return a data source to be used in your tests.

2. Setup Flyway

As mentioned above, Flyway allows you to run migration scripts to setup schemas and tables before each test. We can also use Flyway to clean schemas and tables before each test.

In order to install flyway, add the following to your build.gradle.kts file.

dependencies {  
    testImplementation("org.flywaydb:flyway-core:8.5.10")
}

Update the TestDatabase.kt file to run Flyway migrations.

    fun setup(): DataSource {  
        if (!container.isRunning) {  
            container.start()  
        }  

        val dataSource = setupDataSource(container)
        val flyway = setupFlyway(dataSource)  
        flyway.clean()  
        flyway.migrate()
    }

    private fun setupFlyway(dataSource: DataSource) = Flyway(  
        Flyway.configure()  
            .locations("/migrations")  
            .dataSource(dataSource)  
    )

Notice that we are running flyway.clean() before running migrate. This allows us to clear the database in between tests.

You will also need to add in migration scripts inside the migrations folder in order to be picked up by Flyway. The following migration script can be added as src/test/resources/migrations/V1__create_users_table.sql

CREATE TABLE IF NOT EXISTS users (  
    id   TEXT PRIMARY KEY,  
    name TEXT  
);

3. Write the test

Now that we have the TestDatabase.kt set up, we can write the test. Let's start off with a simple test to insert a single user.

class PostgresRepositoryTest {  
    private lateinit var dataSource: DataSource  
    private lateinit var repository: PostgresRepository  

    @BeforeEach  
    fun setUp() {  
        dataSource = TestDatabase.setup()  
        repository = PostgresRepository(dataSource)  
    }  

    @Test  
    fun `when inserting user, then no exception is thrown`() {  
        val user = User("1", "edrick")  
        repository.insertUsers(listOf(user))    
    }  
}

Currently, there are no assertions and will only fail if the insertUsers method throws an exception. Let's run the test and see what results we get.

Integration Test Blog - No exception.png

Awesome. It looks like no exception is thrown but does not test whether or not the user is actually stored. We can make this test better by asserting that the user is stored in the database. Let's update the test to below and run it.

    @Test  
    fun `when inserting user, then user is stored in database`() {  
        val user = User("1", "edrick")  
        repository.insertUsers(listOf(user))  

        thenAssertUserInDatabase(user)  
    }  

    private val SELECT_USER_SQL = """  
        SELECT id, name  
        FROM users  
        WHERE id = ?  
    """.trimIndent()  

    private fun thenAssertUserInDatabase(user: User) {  
        dataSource.connection.use { connection ->  
            connection.prepareStatement(SELECT_USER_SQL).use { statement ->  
                statement.setString(1, user.id)  
                statement.executeQuery().use { resultSet ->  
                    assertTrue(resultSet.next())  
                    assertEquals(user.name, resultSet.getString("name"))  
                }  
            }  
        }  
    }

Integration Test Blog - Insert single user.png

Looks like the test is still passing. Let's now test that it supports adding multiple users. We can add a new test to the file and run this new test.

@Test  
fun `when inserting users, then users are stored in database`() {  
    val user1 = User("1", "edrick")  
    val user2 = User("2", "josh")  
    repository.insertUsers(listOf(user1, user2))  

    thenAssertUserInDatabase(user1)  
    thenAssertUserInDatabase(user2)  
}

Running this yields the following results.

Integration Test Blog - Insert multiple users (fail).png

Unfortunately, the new test didn't pass. Looking at the logs revealed that it failed at the following line thenAssertUserInDatabase(user1). After carefully looking at the code again and the JDBC docs, you notice that the code is calling execute instead of executeBatch. You realise that execute only inserts the last item in the batch. So you update the code and run the tests.

src/main/kotlin/PostgresRepository.kt

fun insertUsers(dataSource: DataSource, users: List<User>) {  
    dataSource.connection.use { connection ->  
        connection.prepareStatement(INSERT_USER_SQL).use { statement ->  
            for (user in users) {  
                statement.setString(1, user.id)  
                statement.setString(2, user.name)  
                statement.addBatch()  
            }
            statement.executeBatch()  
        }  
    }  
}

Integration Test Blog - Insert multiple users (success).png

The test is passing! Using automated database integration has allowed us to catch this bug early on.

Wrap Up

In this post, we learnt that

  • mistakes can happen when writing integration code

  • manual integration testing is often skipped or not done completely due to its slow and tedious nature

  • automated integration tests can help us catch bugs early

Did you find this article valuable?

Support Edrick Leong by becoming a sponsor. Any amount is appreciated!