Using an in-memory database like H2 in Java have some downsides because the tests could depend on features that in-memory databases can't reproduce and some tests that have passed locally may fail in production. This affects our tests reliability because we won't cover 100% the same scenarios as in our real environment.
Testcontainers comes to the rescue so that we can dockerize our tests. It's a Java library that allows to create any docker instances and manipulate them.
I'm going to be using JUnit5, but if you use JUnit4 is perfectly fine too. Let's start by adding the dependency in the pom.xml. We can add the generic dependency or a more specific one (to have a preconfigured container). When using databases, remember to add their drivers (Testcontainers will not add it for you). Take a look at the list of preconfigured containers in maven repository. You can find for mongoDB, postgresql, cassandra, elasticsearch, rabbitmq, etc.
//Generic dependency
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
//Specific dependency for mysql
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
//driver
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version>
</dependency>
How does Testcontainers work?
- It starts a container with the specific docker image (in my example, I'm using a mysql image).
- Another container called Ryuk will also be started and it's main task is to manage the startup and stop of the container.
One of the good things about this library is its integration with JUnit. To be able to use the next annotations, we need to add this dependency.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.14.3</version>
<scope>test</scope>
</dependency>
From the docs:
- @Testcontainers: is a JUnit Jupiter extension to activate automatic startup and stop of containers used in a test case.
- @Container: is used in conjunction with the @Testcontainers annotation to mark containers that should be managed by the Testcontainers extension.
Creating a generic container
We can create a generic container from any public docker image or a docker-compose. Since this is a generic approach, we need to configure more things than if we already had a preconfigured container.
@Container
private GenericContainer container = new GenericContainer("image_name")
.withExposedPorts(port_number);
withExposedPorts(port), we expose the default internal port (in mysql for example is 3306) from the container that will be mapped to a random port. To retrieve that random port at runtime, we can use the getMappedPort(original_port) or just getFirstMappedPort() method. If we don't expose the port, we'll get en error saying 'Container doesn't expose any ports'. We can also add environment variables to the container with .withEnv(), execute command inside a container (like docker exec), manage our own waiting and startup strategies, etc. Have a look at the documentation for deeper information.
Creating a mysql container
As I said before, we have many preconfigured containers with some out-of-the-box configuration that makes our lives easier. I'm going to use a mysql container for my tests.
We can decide whether we want to start and stop the container once per tests class or once per tests method (we'll see later how to create a singleton container)
//Once per test class
@Container
private static final MySQLContainer mysql = new MySQLContainer("mysql:latest");
// Once per test method
@Container
private MySQLContainer mysql = new MySQLContainer("mysql:latest");
Note: If you are using JUnit4, you can use @Rule and @ClassRule annotations.
The following example starts a mysql container and then runs my HelloEndpointIT class. I'm using static final in the container instance, so the container will be shared between all tests methods. The mysql container give me some methods to configure a specific database name, username and password. If we don't specify this, we will use default values (database name: test, password: test, username: test)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(profiles = "test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Testcontainers
public class HelloEndpointIT {
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
@Container
private static final MySQLContainer mysql = new MySQLContainer("mysql:latest")
.withDatabaseName("demo_db_name")
.withUsername("any_username")
.withPassword("any_passw");
@BeforeAll
private void initDatabaseProperties() {
System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
System.setProperty("spring.datasource.username", mysql.getUsername());
System.setProperty("spring.datasource.password", mysql.getPassword());
}
@Test
public void hello_endpoint_should_return_hello_world() {
HttpHeaders headers = new HttpHeaders();
HttpEntity entity = new HttpEntity(headers);
ResponseEntity<String> response = this.restTemplate.exchange(createUrlWith("/hello"), HttpMethod.GET, entity, String.class);
assertThat(response.getStatusCode(), equalTo(HttpStatus.OK));
assertThat(response.getBody(), equalTo("Hello world"));
}
private String createUrlWith(String endpoint) {
return "http://localhost:" + port + endpoint;
}
}
When the container is ready, I need to set my datasource configuration. We can get the url with its mapped port using the getJdbcUrl() method, as well as the getUsername() and getPassword(). I'm setting this configuration before running my tests using the @BeforeAll annotation provided by JUnit5. In JUnit4 is @BeforeClass.
Creating a singleton container
So far we have seen how to run our container once per test class or method, but I would like to just create an instance for all my tests classes. So, let's see how to run a singleton container before running all our integration tests.
We are going to use static Initializers to initialize the container only once. We need to do it in an abstract class and extend all our IT classes. We also need to manually start the container in our initializer block and when the tests finish, the Ryuk container will take care of stopping it.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(profiles = "test")
public abstract class DemoEndpointIT {
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
private static final MySQLContainer mysql;
static {
mysql = new MySQLContainer("mysql:latest");
mysql.start();
System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
System.setProperty("spring.datasource.username", mysql.getUsername());
System.setProperty("spring.datasource.password", mysql.getPassword());
}
protected String createUrlWith(String endpoint) {
return "http://localhost:" + port + endpoint;
}
protected TestRestTemplate getRestTemplate() {
return this.restTemplate;
}
}
As you saw in the examples, having a mysql container ready for my integrations tests were super easy and almost no configuration was necessary.
Top comments (0)