One Container to Rule Them All - Until It Doesn’t
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, ) { @PostMapping fun forge(): Unit { celebrimbor.forgeTheThreeRings() } @GetMapping fun getAll(): List { 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 { @Transactional override fun forgeTheThreeRings() { treasury.save(Ring(name = "Narya")) treasury.save(Ring(name = "Nenya")) treasury.save(Ring(name = "Vilya")) } override fun getAllTreasures(): List = treasury.findAll().toList() } interface RingTreasury : CrudRepository 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(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, ) { @PostMapping fun craft(): Unit { fëanor.craftSilmarilli() } @GetMapping fun getAll(): List { 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 { @Transactional override fun craftSilmarilli() { treasury.save(Silmaril(fate = "Air")) treasury.save(Silmaril(fate = "Earth")) treasury.save(Silmaril(fate = "Water")) } override fun getAllTreasures(): List = treasury.findAll().toList() } interface SilmarilTreasury : CrudRepository 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(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 AppAbstr

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