
Introduction
One of the reasons containers are used is to have a light-weight environment that does not differ from one machine to another thereby making configuration dependent on operating system and its versions, preinstalled packages etc, which can quickly become a nightmare.
Spring has built-in container support for testing so as to reduce the steps involved to get tests running. 2 of the advantages include: eliminating the need for the time consuming process of creating a test fake and managing the container lifecycle.
We will have a look at how spring helps to make testing and even running in a developer environment with containers easier for us.
Model
We have a Product entity along with its repository, service and test classes. We will use a postgresql container as our database container.
Testing Prior SpringBoot 3.1
We need the following 2 dependencies to run test containers.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
Below is the pre-spring-boot-3.1 way of testing with containers.
@Testcontainers
@SpringBootTest
class ProductServicePreBoot3_1Test {
@Container
static PostgreSQLContainer pgContainer = new PostgreSQLContainer("postgres:11").withDatabaseName("testing")
.withPassword("postgres")
.withUsername("postgres");
@DynamicPropertySource
static void setDatasourceProperties(DynamicPropertyRegistry propertyRegistry) {
propertyRegistry.add("spring.datasource.url", pgContainer::getJdbcUrl);
propertyRegistry.add("spring.datasource.password", pgContainer::getPassword);
propertyRegistry.add("spring.datasource.username", pgContainer::getUsername);
}
@Autowired
private ProductService productService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void saveProduct() {
final Model<Product> productModel = Instancio.of(Product.class).ignore(field("id")).toModel();
final InstancioOfCollectionApi<List<Product>> listInstancioOfCollectionApi = Instancio.ofList(productModel);
final Product product = productService.saveProduct(listInstancioOfCollectionApi.create().get(0));
final UUID productBusinessId = jdbcTemplate.queryForObject("select business_id from product", UUID.class);
assertThat(productBusinessId).isEqualTo(product.getBusinessId());
}
}
The Testcontainers extension (@Testcontainers) will find and invoke the lifecycle methods of all fields that are annotated with Container. The container is declared as a static field so that it is shared between test methods. In other words, each time junit creates a new instance of the test class in order to run a test method, the same container instance is used. Unlike the declaration above which is done on a static field, containers declared as instance fields will be started and stopped for every test method.
The @DynamicPropertySource is a spring annotation adds properties with dynamic values to the Environment’s set of PropertySources. Methods in integration test classes that are annotated with @DynamicPropertySource must be static and must accept a single DynamicPropertyRegistry argument. We use the DynamicPropertyRegistry passed into the method to override the “spring.datasource.url”, “spring.datasource.password” and “spring.datasource.username” properties we had set in our application.yaml file with the information we get from our running container.
Testing Post SpringBoot 3.1
As from spring boot 3.1 we will need the following dependency to run our test containers in the new way that will be shown.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
Below is the same test converted to the new way of running the containers.
@Testcontainers
@SpringBootTest
class ProductServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer pgContainer = new PostgreSQLContainer("postgres:11").withDatabaseName("testing")
.withPassword("postgres")
.withUsername("postgres");
@Autowired
private ProductService productService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void saveProduct() {
final Model<Product> productModel = Instancio.of(Product.class).ignore(field("id")).toModel();
final InstancioOfCollectionApi<List<Product>> listInstancioOfCollectionApi = Instancio.ofList(productModel);
final Product product = productService.saveProduct(listInstancioOfCollectionApi.create().get(0));
final String query = "select business_id from product p where p.id = %d".formatted(product.getId());
final UUID productBusinessId = jdbcTemplate.queryForObject(query, UUID.class);
assertThat(productBusinessId).isEqualTo(product.getBusinessId());
}
}
Now we have @ServiceConnection that makes the @DynamicPropertySource annotation and the annotated method obsolete. Under the hood, spring will use the container’s connection information to update the JdbcConnectionDetails.
Container Reuse Across Test Suites
If we have a test suite with 10 test classes that require a database container, the above mentioned way of running the containers will use the same image to create, start and stop the postgressql container 10 times. Configuring it as below will lead to just 1 container being started and then stopped at the end of the suite.
@TestConfiguration(proxyBeanMethods = false)
public class DataAccessConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:11"));
}
}
The above class should be within the test package since the configuration is needed just for tests.
Tests can now import the container configuration as shown below.
@Import(DataAccessConfig.class)
@SpringBootTest
class ProductServiceContainerAsBeanTest {
@Autowired
private ProductService productService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void saveProduct() {
final Model<Product> productModel = Instancio.of(Product.class).ignore(field("id")).toModel();
final InstancioOfCollectionApi<List<Product>> listInstancioOfCollectionApi = Instancio.ofList(productModel);
final Product product = productService.saveProduct(listInstancioOfCollectionApi.create().get(0));
final UUID productBusinessId = jdbcTemplate.queryForObject("select business_id from product", UUID.class);
assertThat(productBusinessId).isEqualTo(product.getBusinessId());
}
}
Notice the @Import annotation displayed at the top of the class.
Reusing a Container’s connection details
Let’s say we need to log our SQL statements and need the container’s connection details at runtime in order to configure the logging. (See the viewing SQL statements tutorial for a guide on logging SQL statements.) One way will be to inject the container instance and get its connection details, and the second way will be to inject the JdbcConnectionDetails bean. Let’s go with the second way.
@TestConfiguration(proxyBeanMethods = false)
@EnableTransactionManagement
public class DataAccessConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer(@Value("${spring.application.name}") String dbName) {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:11"))
.withDatabaseName(dbName)
.withPassword("postgres")
.withUsername("postgres");
}
@Bean
DataSource spyDataSource(HikariConfig hikariConfig) {
// https://jdbc-observations.github.io/datasource-proxy/docs/snapshot/user-guide/index.html
final DataSource dataSourceSpy = new HikariDataSource(hikariConfig);
SystemOutQueryLoggingListener listener = new SystemOutQueryLoggingListener();
return ProxyDataSourceBuilder.create(dataSourceSpy)
.name("DS-Proxy")
.listener(listener)
.multiline()
.countQuery() //metric collection
.logQueryToSysOut()
.retrieveIsolation()
.writeIsolation()
.logSlowQueryToSysOut(1, TimeUnit.SECONDS)
.build();
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari")
HikariConfig hikariConfig(JdbcConnectionDetails jdbcConnectionDetails) {
final HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setPassword(jdbcConnectionDetails.getPassword());
hikariConfig.setUsername(jdbcConnectionDetails.getUsername());
hikariConfig.setJdbcUrl(jdbcConnectionDetails.getJdbcUrl());
return hikariConfig;
}
}
Local Development Support
Spring boot provides a way of running your application using test configuration. This provides a means of running your application locally without the container dependencies ending up in production jars.
Add the following to the root of your test->java folder.
public class TestTestingApplication {
public static void main(String[] args) {
SpringApplication.from(TestingApplication::main).with(DataAccessConfig.class).run(args);
}
}
Run the application using the following command.
mvn spring-boot:test-run
If you are using spring devtools, the container will be restarted each time devtools restarts your application. To avoid the restart, add the @RestartScope annotation to the container bean config.
@Bean
@ServiceConnection
@RestartScope
public PostgreSQLContainer<?> postgreSQLContainer() {
return new PostgreSQLContainer(DockerImageName.parse("postgres:11"))
.withUsername("postgres")
.withPassword("postgres")
.withDatabaseName("testing");
}
Conclusion
We have seen how spring boot can help us run tests without having to first run our containers or even without complex docker compose files that do that for us. Now you can use the spring boot maven plugin to run or test your application as before without further steps.
The accompanying code can be found here. Also Checkout the data-access-test-container branch.
Leave a comment