Héctor VallsFreelance Software Engineer

Integration testing made easy with Testcontainers

Say we have a REST API exposing a single endpoint to fetch customers, GET /customers, which are stored in a MariaDB database.

Let's test the next scenario:

Given two customers stored in our database
When we call GET/customers endpoint
Then we obtain a JSON array with two customers

First, we need a MariaDB instance up and running. Also, before the test, we need two customers to exist in the database (see Given block in the test scenario).

This is the test code (I'm using RestAsssured to test the API):

@Test
fun test() {
    //Given 2 customers in the database
    database.exec("INSERT INTO CUSTOMERS (name, email) VALUES('John', 'jdoe@mail.com');");
    database.exec("INSERT INTO CUSTOMERS (name, email) VALUES('Steve', 'steve@mail.com');");

    when().get("/customers")
    .then()
    .statusCode(200)
    .body("size()", is(2))
}

But, how can we have a clean MariaDB database up and running before the test execution? Here is where Testcontainers comes into play.

Testcontainers allows us to start and stop Docker containers programmatically. Just what we need. We are going to start a MariaDB container, run the test against it and, finally, stop and remove the container. This is the code:

@Testcontainers
class CustomersIntegrationTests {

    lateinit var database: Database

    @Container
    val mariaDBContainer = MariaDBContainer()
        .withExposedPorts(3306)
        .withClasspathResourceMapping(
            "createTables.sql",
            "/docker-entrypoint-initdb.d/createTables.sql",
            BindMode.READ_ONLY
        )

    @BeforeEach
    fun setUp() {
        database = Database(mariaDBContainer.jdbcUrl)
        database.connect()
    }

    @AfterEach
    fun tearDown() {
        database.close()
    }

    //our test here

}

Step by step.

  • @Testcontainers is a JUnit 5 extension that allows to use the library in JUnit 5 engine.
  • @Container attaches the container to the JUnit test lifecycle. This means that before each test, container will be created, and destroyed once test has finished.
  • MariaDBContainer class comes from{" "} Testcontainers MariaDB module. It provides a jdbcUrl property that will be used to connect to the database.
  • We need the "CUSTOMERS" table to be created in the database before inserting test data. To do this, we create a createTables.sql file, containing CREATE TABLE statement, and put it in src/test/resources dir. Then, we map that file inside the /docker-entrypoint-initdb.d dir in the container, using a Docker volume. That's exactly what this code does:
.withClasspathResourceMapping("createTables.sql", "/docker-entrypoint-initdb.d/createTables.sql", BindMode.READ_ONLY)

When MariaDB container is started, automatically, it will execute all the SQL files inside /docker-entrypoint-initdb.d directory, so our table will be created. (See "Initializing a fresh instance" in https://hub.docker.com/_/mariadb )

Finally, we just need to start our server and stop it after test is executed. This is the final version of our test class code:

@Testcontainers
class CustomersIntegrationTests {

    lateinit var database: Database
    lateinit var server: Server

    @Container
    val mariaDBContainer = MariaDBContainer()
        .withExposedPorts(3306)
        .withClasspathResourceMapping(
            "createTables.sql",
            "/docker-entrypoint-initdb.d/createTables.sql",
            BindMode.READ_ONLY
        )

    @BeforeEach
    fun setUp() {
        database = Database(mariaDBContainer.jdbcUrl)
        database.connect()
        server = Server(database)
        server.start()
    }

    @AfterEach
    fun tearDown() {
        database.close()
        server.stop()
    }

    @Test
    fun test() {
      //Given 2 customers in the database
      database.exec("INSERT INTO CUSTOMERS (name, email) VALUES('John', 'jdoe@mail.com');");
      database.exec("INSERT INTO CUSTOMERS (name, email) VALUES('Steve', 'steve@mail.com');");

      when().get("/customers")
        .then()
        .statusCode(200)
        .body("size()", is(2))
    }

}

That's all.

I highly recommend you to use Testcontainers in your integration tests. It provides a great variety of pre-built modules such as MariaDB, Redis or Kafka. However, you can also create your own container from a generic one.