skip to content

In Testcontainers, what is the singleton container pattern and when do you reach for it?

level: middleimportance: must knowfreq 55%

answer

  1. One container, many test classes
  2. Who owns the lifetime here?
  3. static field, manual start()
  4. Never call stop(); Ryuk cleans up
  5. Cost is shared, dirty state

basics

~20 s

The singleton pattern starts one container in a static field of a shared base class, outside the JUnit extension, so every test class in the JVM reuses that one container instead of paying a fresh startup per class.

solid answer

~50 s

You declare the container as a `static` field on an abstract base class (or a Kotlin `object`), call `start()` in the static initializer, and never call `stop()`. Test classes extend the base and read the container's connection details. Because the field is initialized once per class loader, the container's lifetime is the **test JVM**, not a test class — so a suite of forty classes pays one startup instead of forty. You do not let the JUnit extension manage this container; the point is precisely that its lifecycle is wider than any single class. Cleanup is left to the Ryuk reaper, which removes the container when the JVM's session ends, so nothing leaks. The price is shared state: every class now runs against the same database or broker, so each test has to leave the data in a defined condition, and tests must not depend on execution order.

code

kotlin · 9 lines
kotlin
abstract class PostgresTestBase {
    companion object {
        @JvmStatic
        protected val postgres: PostgreSQLContainer<*> =
            PostgreSQLContainer("postgres:16-alpine").apply {
                start() // no stop(): Ryuk removes it when the JVM exits
            }
    }
}

go deeper

for a junior

Recall that a container can be started once in a static field of a base class and shared, rather than started again for every test class.

for a middle

Explain why the field is static, why start() is called manually and stop() is not, and that the container's lifetime is the test JVM.

for a senior

Demonstrate the discipline the pattern demands: a data cleanup contract per test, order independence, and one-time setup placed correctly.

for a principal

Own it as a suite-wide convention — a shared test-support base module, a documented cleanup contract, and a budget for how many containers a forked build starts.

## The problem it solves Container startup is measured in seconds. Multiply that by the number of test classes in a real suite and it becomes the dominant cost of the build. The singleton pattern removes the multiplication: one container, started once, shared by every class in the JVM. ## The shape of the pattern An abstract base class holds the container in a static field and starts it in the static initializer: The key properties are that the field is `static` (one instance per class loader), that `start()` is called explicitly rather than by a framework, and that `stop()` is never called. Test classes extend the base and read `getJdbcUrl()`, `getHost()` and friends. In Kotlin the same thing is usually a `companion object` or a top-level `object` initialized lazily on first access. ## Why manual lifecycle control here The JUnit extension's job is to bind a container's lifetime to a test class. That is exactly the binding you are trying to escape: you want a lifetime *wider* than any class, spanning everything the JVM runs. So you take the lifecycle into your own hands. The two approaches are not mixed for the same container — if a framework is stopping it after each class, it is not a singleton. ## Who cleans up Nothing in your code stops the container, so the obvious worry is a leak. The Ryuk reaper handles it: Testcontainers starts a small sidecar container that holds a connection to the test JVM and removes the resources created by that session when the connection drops — that is, when the JVM exits, normally or not. This is why you can safely omit `stop()` and why an `@AfterAll` teardown adds nothing. ## The cost you take on Sharing one container across classes means sharing state. Rows written by class A are visible to class B; a Kafka topic created by one test still exists for the next. Two consequences follow. First, every test must be responsible for the state it depends on: clean the tables it touches, or use per-test schemas, or generate unique identifiers. Second, order dependence becomes a real hazard — a suite that passes locally and fails in CI because the classes ran in a different order is the classic symptom. One-time setup also needs a home. Schema migrations, reference data, and topic creation typically run once, in the same static initializer, right after `start()`. ## Scope is the JVM, not the machine A singleton is per class loader, so a build that forks several test JVMs gets one container per fork, not one overall. That is usually what you want, and it is the honest answer to "how many containers will my parallel build start?". Extending sharing *beyond* the JVM, across whole runs, is a different feature — opt-in container reuse — with a different set of trade-offs. ## Interview framing A strong answer names the mechanism (static field, manual `start()`, no `stop()`), names the payoff (one startup per JVM), and names the price (shared state, ordering discipline, Ryuk as the cleanup path) without being prompted.

  • Where do schema migrations belong in the singleton pattern?
    In the same static initializer, immediately after start(), so they run exactly once per JVM. Running them per test class wastes time and can race when tests execute in parallel. If individual tests need a clean slate they should reset data — truncate, or use a per-test schema — rather than re-running migrations.
  • Does the singleton container need an @AfterAll that stops it?
    No, and adding one defeats the pattern: it would stop the container after the first class that finishes. Leave it running. The Ryuk reaper holds a connection to the test JVM and removes the session's containers when that JVM exits, so the container disappears when the build does.
  • How many containers does a build with four forked test JVMs start?
    Four — one per JVM, because a static field exists once per class loader and each fork has its own. That is usually acceptable, but it is the number you must budget memory for on the build machine. If it is not acceptable, reduce forks rather than trying to share a container across JVMs.

saying these in an interview costs you the question

  • Adds an @AfterAll that stops the shared container
  • Claims a static field gives one container per machine
  • Ignores that shared state makes tests order-dependent
  • Thinks the JUnit extension can span multiple test classes
  • Runs migrations again in every test class

context