1. Introduction
Integration testing plays a crucial role in ensuring the reliability and correctness of software applications. Testcontainers provide a powerful solution for running isolated and reproducible integration tests. This tutorial will explore integrating Testcontainers with Kotest, a flexible and powerful testing framework for Kotlin. We’ll discuss the steps involved in running a test container inside a Kotest test, covering setup, test execution, and best practices.
2. Setting Up Dependencies
We’ll need to add a few dependencies to our project first. Additionally, we may need to configure our containerization platform, such as Docker, to enable running containers. Let’s make sure Docker is installed and running on our development environment.
2.1. Kotest Dependencies
To start with test containers in Kotest, we must add the necessary dependencies to our project. Let’s include the Kotest framework and the Testcontainers library, along with the Kotest extension specifically designed for Testcontainers:
<dependency>
<groupId>io.kotest</groupId>
<artifactId>kotest-runner-junit5</artifactId>
<version>5.8.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.kotest.extensions</groupId>
<artifactId>kotest-extensions-testcontainers</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
We can use kotest-runner-junit5 to write our tests, and kotest-extensions-testcontainers allows us to integrate with Testcontainers.
2.2. Testcontainers Dependencies
We’ll also need to ensure the core Testcontainers dependencies are available. We’re also using a special Testcontainers dependency to assist with a MySQL instance:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.18.3</version>
<scope>test</scope>
</dependency>
With the necessary dependencies in place, we are now ready to write integration tests that depend on containers in Kotest using the Testcontainers extension.
3. Using a Database Container
To demonstrate using a database container, let’s consider a scenario with a Kotlin class DatabaseService that interacts with a MySQL database. We want to write integration tests for this service using a MySQL container. To do this, we will use FunSpec, one of Kotest’s Test Styles.
3.1. Creating a Testcontainer in the FunSpec
Before we write a test using the database, let’s set up the database container.
First, we need to define a test class and create a Testcontainer:
class TestContainersSamples : FunSpec({
val mysql = MySQLContainer("mysql:8")
})
We can create a DataSource object by calling Kotest’s install() function, installing our container inside our tests. We must then bridge from Testcontainers to Kotest by creating a JdbcDatabaseContainerExtension:
class TestContainersSamples : FunSpec({
val mysql = MySQLContainer("mysql:8")
val dataSource: DataSource = install(JdbcDatabaseContainerExtension(mysql))
})
3.2. Using the Testcontainer
With the DataSource, we can create our DatabaseService and then, finally, write tests for it:
class TestContainersSamples : FunSpec({
val mysql = MySQLContainer("mysql:8")
val dataSource: DataSource = install(JdbcDatabaseContainerExtension(mysql))
val service = DatabaseService(dataSource)
test("Inserting in database should persist an object") {
service.insert(Person("Leo"))
service.insert(Person("Colman"))
service.all() shouldBe listOf(Person("Leo"), Person("Colman"))
}
})
Inside the test specification, we’ll call the insert() method to insert two Person objects into the database. Then, we’ll assert that the result of service.all() is equal to the expected list of Person objects.
With Kotest, we can seamlessly incorporate containerized services into our integration tests, ensuring reliable and comprehensive testing of our application’s data interactions.
4. Configuration and Customization
Test containers provide configuration options to modify container settings such as ports, environment variables, network configurations, and more. These configurations can be applied when creating the container instance.
For example, let’s consider our previous MySQL container example. We can configure it to expose a specific port to the host machine:
val exposedMySQL = MySQLContainer("mysql:8").apply {
withExposedPorts(3306)
}
In this case, we configure the MySQLContainer to expose port 3306 to the host machine, allowing external applications to communicate with the containerized database.
We can also configure how the container will behave in our FunSpec lifecycle. The JdbcDatabaseContainerExtension constructor is flexible and allows many configuration options, such as beforeStart(), afterStart(), beforeTest(), and afterTest(). Let’s see some of them in action:
val dataSource: DataSource = install(
JdbcDatabaseContainerExtension(
mysql,
mode = ContainerLifecycleMode.Spec,
beforeStart = {},
afterStart = {},
beforeShutdown = {})
)
These examples demonstrate how test containers can be configured and customized for specific testing requirements. With these options, we can fine-tune the behavior of our containers and establish the desired testing environment.
Refer to the official documentation of Testcontainers and to Kotest’s official documentation for a comprehensive list of available configuration options and customization capabilities. With the flexibility provided by test container configuration and customization, we can create tailored and effective integration tests to validate the behavior of our applications.
5. Conclusion
In this article, we explored how to run a test container inside a Kotest test. We delved into container configuration and customization options, allowing us to fine-tune the behavior of our containers to suit our specific testing requirements.
Testcontainers provide a powerful and convenient way to set up and manage Docker containers for integration testing. By incorporating Testcontainers into our test suite, we can seamlessly integrate external dependencies such as databases, message brokers, or other services required by our application.
This allows us to create comprehensive and reliable integration tests that accurately simulate our application’s runtime environment. It also enables us to identify and resolve issues early in the development lifecycle, leading to more robust and resilient software. As always, the code used in this article is available on GitHub.