DEV Community

Cover image for One Container to Rule Them All - Until It Doesn’t
Petr Filaretov
Petr Filaretov

Posted on

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

"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>
Enter fullscreen mode Exit fullscreen mode

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 {
}
Enter fullscreen mode Exit fullscreen mode

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()
        }

        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

(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 }
    }
}

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And it fails 🫤

2 tests completed, 1 failed
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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()
        }

        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 call stop() 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()
        }

        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • Subscribe to reuse in the container definition:
private val postgresContainer = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
    .withReuse(true)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The test report provides details for the failed tests:

AssertionFailedError: 
expected: 3
 but was: 6
Enter fullscreen mode Exit fullscreen mode

Assertion failed for the following code:

assertThat(list.size).isEqualTo(3)
Enter fullscreen mode Exit fullscreen mode

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()
        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode
class SilmarilliApiTest : AppAbstractTest() {

    @BeforeEach
    fun setUp() {
        silmarilTreasury.deleteAll()
        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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()
        }

        // ...
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)