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

Mar 11, 2025 - 17:43
 0
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<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