TL;DR
Start the container once, let it run throughout, avoid reusable containers unless necessary, and always prepare the data before tests.
Intro
When working with Testcontainers I found a couple of things that are not very obvious at first glance. Especially when you are just starting to use Testcontainers. Let's check them out.
The full source code is available on Github: pfilaretov42/spring-testcontainers-tips.
Stop the Container
The API
Say we have a Spring Boot application with endpoints to forge and get the rings:
@RestController
@RequestMapping("/rings")
class RingController(
private val celebrimbor: ElvenSmith<Ring>,
) {
@PostMapping
fun forge(): Unit {
celebrimbor.forgeTheThreeRings()
}
@GetMapping
fun getAll(): List<String> {
return celebrimbor.getAllTreasures().map { it.name }
}
}
"Forging," in our case, is simply saving an entity in PostgreSQL:
@Service
class Celebrimbor(
private val treasury: RingTreasury,
) : ElvenSmith<Ring> {
@Transactional
override fun forgeTheThreeRings() {
treasury.save(Ring(name = "Narya"))
treasury.save(Ring(name = "Nenya"))
treasury.save(Ring(name = "Vilya"))
}
override fun getAllTreasures(): List<Ring> = treasury.findAll().toList()
}
interface RingTreasury : CrudRepository<Ring, UUID>
The Test
Now, we want to write some tests for this API, and we will use @SpringBootTest
.
We want the Spring context to start only once for the whole test suite, so let's add an abstract test class as a base for all tests:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
}
To deal with the database, we will use Testcontainers.
There are a handful of ways to start Testcontainers, including JUnit4 annotations, JUnit5 annotations, ApplicationContextInitializer, JDBC URL scheme, and manual container lifecycle control. Let's choose manual control for now as the least "magic" option:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
companion object {
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
init {
logger.info { "STARTING CONTAINER" }
postgresContainer.start()
}
@JvmStatic
@AfterAll
fun tearDown() {
logger.info { "STOPPING CONTAINER" }
postgresContainer.stop()
}
// ...
}
// ...
}
Here we have a postgresContainer
that starts when tests are loaded (init
block) and stops after all tests are done (@AfterAll
method).
Now, let's add a test that extends AppAbstractTest
...
class RingsApiTest : AppAbstractTest() {
@Test
fun `should forge the three rings`() {
logger.info { "TEST: should forge the three rings" }
testRestTemplate.postForObject<Unit>(endpointUrl, "{}")
val list = testRestTemplate.getForObject(endpointUrl, List::class.java)
assertThat(list.size).isEqualTo(3)
}
// ...
}
...and run it in the IDE. It passes, and we can see the following logs in the output:
INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest : TEST: should forge the three rings
...
INFO AppAbstractTest: STOPPING CONTAINER
(I will be cutting out some irrelevant data here and below from every log string for clarity)
So far, so good.
Another API
All right, let's add another API (which we probably should have started with) - to craft the Silmarilli:
@RestController
@RequestMapping("/silmarilli")
class SilmarilliController(
private val fëanor: ElvenSmith<Silmaril>,
) {
@PostMapping
fun craft(): Unit {
fëanor.craftSilmarilli()
}
@GetMapping
fun getAll(): List<String> {
return fëanor.getAllTreasures().map { it.fate }
}
}
Again, "crafting" here is simply saving an entity in the database, but in a different table:
@Service
class Fëanor(
private val treasury: SilmarilTreasury,
) : ElvenSmith<Silmaril> {
@Transactional
override fun craftSilmarilli() {
treasury.save(Silmaril(fate = "Air"))
treasury.save(Silmaril(fate = "Earth"))
treasury.save(Silmaril(fate = "Water"))
}
override fun getAllTreasures(): List<Silmaril> = treasury.findAll().toList()
}
interface SilmarilTreasury : CrudRepository<Silmaril, UUID>
Another Test
Now, let's add a test for the Silmarilli API. It will be pretty similar to the Rings API test:
class SilmarilliApiTest : AppAbstractTest() {
@Test
fun `should craft Silmarilli`() {
logger.info { "TEST: should craft Silmarilli" }
testRestTemplate.postForObject<Unit>(endpointUrl, "{}")
val list = testRestTemplate.getForObject(endpointUrl, List::class.java)
assertThat(list.size).isEqualTo(3)
}
// ...
}
If we run the test, it passes, and we get the following logs:
INFO AppAbstractTest -- STARTING CONTAINER
...
INFO SilmarilliApiTest: TEST: should craft Silmarilli
...
INFO AppAbstractTest : STOPPING CONTAINER
Now, let's check that project builds with Gradle (by the way, check out my tiny post on how to enable logging from tests in the Gradle build):
./gradlew clean build
And it fails 🫤
2 tests completed, 1 failed
What's going on here? Let's check the build logs:
INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest : TEST: should forge the three rings
...
INFO AppAbstractTest : STOPPING CONTAINER
...
INFO SilmarilliApiTest : TEST: should craft Silmarilli
WARN com.zaxxer.hikari.pool.ProxyConnection : HikariPool-1 - Connection org.postgresql.jdbc.PgConnection@64381526 marked as broken because of SQLSTATE(08006), ErrorCode(0)
org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend.
...
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.TransactionSystemException: JDBC rollback failed] with root cause
java.sql.SQLException: Connection is closed
...
A-ha! We can see here that the Postgres container is started before all tests, and then stopped after RingsApiTest
class is executed, not after all test classes.
And no new Postgres container is started for the SilmarilliApiTest
. It happened because we started the container in the static (companion object
) init
block, and the class is loaded once for all tests. And that's why the SilmarilliApiTest
fails - the app cannot connect to the database.
So, how do we fix it? Looks easy: the container is not starting for the SilmarilliApiTest
? Let's start it then in the @BeforeAll
method!
And now our AppAbstractTest
class would look like this:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
companion object {
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
@JvmStatic
@BeforeAll
fun setUpAll() {
logger.info { "STARTING CONTAINER" }
postgresContainer.start()
}
@JvmStatic
@AfterAll
fun tearDown() {
logger.info { "STOPPING CONTAINER" }
postgresContainer.stop()
}
// ...
}
// ...
}
Let's run the Gradle build again, and what do we get? The second test fails again:
INFO AppAbstractTest -- STARTING CONTAINER
...
INFO RingsApiTest : TEST: should forge the three rings
...
INFO AppAbstractTest : STOPPING CONTAINER
...
INFO AppAbstractTest : STARTING CONTAINER
...
INFO SilmarilliApiTest : TEST: should craft Silmarilli
WARN com.zaxxer.hikari.pool.PoolBase : HikariPool-1 - Failed to validate connection org.postgresql.jdbc.PgConnection@5513a46b (This connection has been closed.). Possibly consider using a shorter maxLifetime value.
...
ERROR o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction] with root cause
java.net.ConnectException: Connection refused
Okay, now the container is starting for the SilmarilliApiTest
as expected. However, the application under test cannot connect to it 🤔
This is due to the Spring application context being started once for the whole test suite (thanks to the AppAbstractTest
), and the connection details for the second container are not picked up. That's why we have connection problems for the SilmarilliApiTest
.
Looks like we need to reuse the first container for all tests. And there is a Testcontainers' feature called reusable containers. It looks promising; let's try it out!
Reusable Containers
The documentation for reusable containers says:
start the container manually by calling
start()
method, do not callstop()
method directly or indirectly
Looks easy, we just need to drop @BeforeAll
and @AfterAll
methods and get the init
block back:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
companion object {
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
init {
logger.info { "STARTING CONTAINER" }
postgresContainer.start()
}
// ...
}
// ...
}
Then, we need to enable reusable containers. According to the same docs, we just need to
- Add the following line to the
~/.testcontainers.properties
file:
testcontainers.reuse.enable=true
- Subscribe to reuse in the container definition:
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
.withReuse(true)
And that's it!
Let's run the Gradle build and... it is successful! 🎉 Logs say:
INFO AppAbstractTest -- STARTING CONTAINER
...
INFO SilmarilliApiTest : TEST: should craft Silmarilli
...
INFO RingsApiTest : TEST: should forge the three rings
So, the container is started before the first test and reused for the second test. Great!
Let's run the build again, just in case. And now it fails 😖
2 tests completed, 2 failed
The test report provides details for the failed tests:
AssertionFailedError:
expected: 3
but was: 6
Assertion failed for the following code:
assertThat(list.size).isEqualTo(3)
Apparently, this happens because the container started during the first execution of the tests is not stopped at all. It remains running, and the database still holds the three rings and three Silmarilli added during the tests. When all tests are executed a second time, they add three more rings and three more Silmarilli, causing the assertion to fail.
So, how do we make all tests run consistently? 🤔
Fixing Tests
As we can see, reusable containers are not stopped between test executions, and data added during each test is not deleted. Here is what we can do about it.
Clear Database
We need to delete the data in the database before each test:
class RingsApiTest : AppAbstractTest() {
@BeforeEach
fun setUp() {
ringTreasury.deleteAll()
// ...
}
// ...
}
class SilmarilliApiTest : AppAbstractTest() {
@BeforeEach
fun setUp() {
silmarilTreasury.deleteAll()
// ...
}
// ...
}
This ensures that each test starts with a fresh database table, and test results are not affected by leftovers from the other tests or previous test executions.
Do Not Use Reusable Containers
We don't actually need reusable containers since we have neither hundreds of containers nor very heavy containers that could drastically slow down the test execution.
Furthermore, "Reusable" is an experimental feature that should be used carefully. Here is what the documentation says:
Reusable containers are not suited for CI usage and as an experimental feature not all Testcontainers features are fully working (e.g., resource cleanup or networking).
So, we will let Testcontainers create a fresh container for every test execution. To do this, we need to:
- Drop the
testcontainers.reuse.enable
property in the~/.testcontainers.properties
file. - Drop the
withReuse(true)
property of the container. - Start the container once in the
init
block. - Do not stop the container for each test class, Ryuk will take care of stopping it at the end of the test suite.
Here is how the abstract test looks like now:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
abstract class AppAbstractTest {
companion object {
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
init {
logger.info { "STARTING CONTAINER" }
postgresContainer.start()
}
// ...
}
// ...
}
Now, let's run the Gradle build and... it is successful 🎉 We can run it again and again, and it still passes.
Conclusion
We looked at some ways Testcontainers can be used, and here are the key takeaways:
- If you use
@SpringBootTest
and share the same context between test classes, then likely you do not need a container per test class or per every single test, so just start the container once for the whole test suite. - Do not stop the container, leave it to
- Ryuk if you start the container manually, or
- JUnit Jupiter extension if you use the
@Testcontainers
and@Container
annotations.
- Do not use reusable containers until you really need them. Make sure you understand the pros and cons of this approach for both local development and CI/CD pipelines.
- Clean up (or properly prepare) the data in a database before each test.
Dream your code, code your dream.
Top comments (0)