Getting started with Testcontainers for Java

  • Java
  • PostgreSQL
Get the code

Testcontainers is a testing library that provides easy and lightweight APIs for bootstrapping integration tests with real services wrapped in Docker containers. Using Testcontainers, you can write tests talking to the same type of services you use in production without mocks or in-memory services.

NoteIf you are new to Testcontainers then please read What is Testcontainers, and why should you use it? to learn more about Testcontainers.

Let us look at how we can use Testcontainers for testing a Java application using a Postgres database.

Create a Java project with Maven

Create a Java project with Maven build tool support from your favorite IDE. We are using Maven in this article, but you can use Gradle if you prefer. Once the project is created, add the following dependencies to the pom.xml.

<dependencies>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <version>42.7.3</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.5.6</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.5</version>
        </plugin>
    </plugins>
</build>

We have added a Postgres JDBC driver for talking to the Postgres database, logback-classic for logging and junit-jupiter for testing with JUnit 5. Also, we have used the latest version of maven-surefire-plugin to support JUnit 5 tests.

Implement business logic

We are going to create CustomerService class to manage customer details.

First let us create a Customer class as follows:

package com.testcontainers.demo;

public record Customer(Long id, String name) {}

Create DBConnectionProvider.java class to hold JDBC connection parameters and create a method to get database Connection as follows:

package com.testcontainers.demo;

import java.sql.Connection;
import java.sql.DriverManager;

class DBConnectionProvider {

  private final String url;
  private final String username;
  private final String password;

  public DBConnectionProvider(String url, String username, String password) {
    this.url = url;
    this.username = username;
    this.password = password;
  }

  Connection getConnection() {
    try {
      return DriverManager.getConnection(url, username, password);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }
}

Create CustomerService.java class and add the following code:

package com.testcontainers.demo;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class CustomerService {

  private final DBConnectionProvider connectionProvider;

  public CustomerService(DBConnectionProvider connectionProvider) {
    this.connectionProvider = connectionProvider;
    createCustomersTableIfNotExists();
  }

  public void createCustomer(Customer customer) {
    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "insert into customers(id,name) values(?,?)"
      );
      pstmt.setLong(1, customer.id());
      pstmt.setString(2, customer.name());
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public List<Customer> getAllCustomers() {
    List<Customer> customers = new ArrayList<>();

    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        "select id,name from customers"
      );
      ResultSet rs = pstmt.executeQuery();
      while (rs.next()) {
        long id = rs.getLong("id");
        String name = rs.getString("name");
        customers.add(new Customer(id, name));
      }
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
    return customers;
  }

  private void createCustomersTableIfNotExists() {
    try (Connection conn = this.connectionProvider.getConnection()) {
      PreparedStatement pstmt = conn.prepareStatement(
        """
        create table if not exists customers (
            id bigint not null,
            name varchar not null,
            primary key (id)
        )
        """
      );
      pstmt.execute();
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }
}

Let us understand what is going on in CustomerService class.

  • We are calling connectionProvider.getConnection() method to get a database Connection using JDBC API

  • We have the createCustomersTableIfNotExists() method that creates the customers table if it does not already exist.

  • We have createCustomer() method that inserts a new customer record into the database.

  • We have the getAllCustomers() method that fetches all rows from customers table, populates data into Customer objects and returns a list of Customer objects.

Now let us see how we can test CustomerService logic using Testcontainers.

Add Testcontainers dependencies

Before writing Testcontainers based tests let’s add Testcontainers dependencies in pom.xml as follows:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.8</version>
    <scope>test</scope>
</dependency>

As we are using a Postgres database for our application, we added the Testcontainers Postgres module as a test dependency.

Write test using Testcontainers

Create CustomerServiceTest.java under src/test/java with the following code:

package com.testcontainers.demo;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;

class CustomerServiceTest {

  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
    "postgres:16-alpine"
  );

  CustomerService customerService;

  @BeforeAll
  static void beforeAll() {
    postgres.start();
  }

  @AfterAll
  static void afterAll() {
    postgres.stop();
  }

  @BeforeEach
  void setUp() {
    DBConnectionProvider connectionProvider = new DBConnectionProvider(
      postgres.getJdbcUrl(),
      postgres.getUsername(),
      postgres.getPassword()
    );
    customerService = new CustomerService(connectionProvider);
  }

  @Test
  void shouldGetCustomers() {
    customerService.createCustomer(new Customer(1L, "George"));
    customerService.createCustomer(new Customer(2L, "John"));

    List<Customer> customers = customerService.getAllCustomers();
    assertEquals(2, customers.size());
  }
}

Let us understand the code in our CustomerServiceTest.

  • We declared PostgreSQLContainer by passing the Docker image name postgres:16-alpine.

  • The Postgres container is started using JUnit 5 @BeforeAll callback, which gets executed before running any test methods.

  • In @BeforeEach callback method, which gets executed before running every test method, we have created a DBConnectionProvider instance by passing the JDBC connection parameters obtained from the Postgres container and also created a CustomerService instance. In the CustomerService constructor, we are creating the customers table if it does not already exist.

  • We have a shouldGetCustomers() test where we are inserting 2 customer records into the database, fetching all the existing customers and asserting the number of customers.

  • Finally, we are stopping the postgres container in @AfterAll callback method, which gets executed after all the test methods in that class are executed.

If you run the CustomerServiceTest you can see in the logs that Testcontainers pulled the Postgres Docker image from DockerHub if not already available locally, started the container and executed the test.

Voila!!! You have your first Testcontainers-based test running.

Conclusion

We have explored how to use Testcontainers for Java library for testing a Java application using a Postgres database.

We have seen how writing an integration test using Testcontainers is very similar to writing a unit test which you can run from your IDE. Also, any of your teammates can clone the project and run tests without installing Postgres on their computers.

In addition to Postgres, Testcontainers provides dedicated modules to many commonly used SQL databases, NoSQL databases, messaging queues, etc. You can use Testcontainers to run any containerized dependency for your tests!

You can explore more about Testcontainers at https://www.testcontainers.com/.