skip to content

A JUnit 5 test class annotated @TestInstance(TestInstance.Lifecycle.PER_CLASS) passes when a single method is run in the IDE but fails when the whole class runs, and it fails intermittently once method-level parallel execution is switched on. What are the failure modes of holding mutable state in a shared test instance, and how do you contain them?

level: seniorimportance: should knowfreq 44%

answer

  1. passes alone / fails in suite — and the reverse
  2. shared instance = shared mutable state
  3. immutable after @BeforeAll
  4. @Execution(SAME_THREAD), @ResourceLock
  5. random method order to expose coupling

basics

~20 s

With one instance for the class, mutations in one test are visible to the next, so tests become order-dependent (pass alone, fail together, or the reverse) and, under parallel methods, race on the same fields. Contain it: keep shared fields immutable after setup, reset mutable ones in @BeforeEach, and serialize the class with @Execution(SAME_THREAD) or @ResourceLock.

solid answer

~50 s

Three distinct failure modes, and they need different fixes. **Order dependence.** State written by test A is still there for test B. B may pass only because A ran (fails when run alone or when the suite is filtered/sharded) or fail only because A ran (passes alone). Fix by resetting every mutable field in `@BeforeEach`, or by not storing it in a field at all — build it locally in the test. **Concurrency.** Under `@Execution(CONCURRENT)` at method level, all methods hit the same object, so unsynchronized collections and counters race. Fix by making shared fields effectively immutable after `@BeforeAll`, or constraining the class with `@Execution(SAME_THREAD)`, or declaring `@ResourceLock` on the shared resource so Jupiter serializes access. **Leaked non-instance state**, which `PER_CLASS` merely makes easier to overlook: statics, mocks, system properties, database rows. To detect it, run methods in a randomized order and run each test individually in CI periodically; both surface coupling immediately.

code

java · 18 lines
java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Execution(ExecutionMode.SAME_THREAD)   // methods share one instance
class OrderServiceTest {

    private OrderService service;       // built once, never mutated
    private InMemoryOrderRepo repo;     // mutable -> reset per test

    @BeforeAll
    void bootstrap() {
        repo = new InMemoryOrderRepo();
        service = new OrderService(repo);
    }

    @BeforeEach
    void clearRepo() {
        repo.clear();                   // no leakage between methods
    }
}

go deeper

for a junior

Recognize the shape of the bug: one instance for the class means one test can leave state behind for the next, so tests behave differently alone than together.

for a middle

Separate order dependence from concurrency, and name the concrete fixes: reset in @BeforeEach, keep shared fields immutable, do not store mutable data in fields.

for a senior

Diagnose systematically (run alone, bisect predecessors, flip the lifecycle to test the hypothesis) and apply the right containment — @Execution(SAME_THREAD) or @ResourceLock — plus detection via randomized order.

for a principal

Set the standing rule: shared instances hold only immutable expensive fixtures, and back it with CI signals (random order, run-each-alone, parallel-on-CI) so coupling cannot re-enter silently.

## Why the shared instance changes the risk profile Jupiter's default `PER_METHOD` lifecycle constructs the test class again for every test method, so instance fields cannot carry information forward. `@TestInstance(Lifecycle.PER_CLASS)` removes that guarantee: one object serves every test method in the class. Nothing else about the framework changes — but the class's fields have quietly become shared mutable state, with all the classic consequences. ## Failure mode 1: order dependence Call the two directions by name, because interviewers listen for both. - **Passes alone, fails in the suite.** A previous test left the shared field in a state the current test does not expect: a list with leftover elements, a counter that is no longer zero, a stubbed collaborator, a closed resource, a client whose auth token was invalidated. - **Passes in the suite, fails alone.** The current test silently depends on setup another test performed — an entity another test inserted, a cache another test warmed. Filtering to one method, running in an IDE, or sharding the class across CI workers all break it. Both are the same defect: the test's precondition is not fully established by its own setup. The diagnostic move is to run the failing method by itself and then run it after the suspected predecessor; if the outcomes differ, the shared instance (or some other shared state) is the carrier. ## Failure mode 2: concurrency on the shared instance JUnit Platform parallel execution can run test **methods** concurrently. Under `PER_METHOD` that is comparatively safe, because each method works on its own object. Under `PER_CLASS`, concurrent methods invoke the same object, and every non-final, non-thread-safe field is a race: - `ArrayList`/`HashMap` fields mutated from several threads — corrupted state, `ConcurrentModificationException`, lost updates. - Counters and flags — lost increments, assertions that pass or fail depending on interleaving. - Mock objects shared across threads — verifications that see invocations from a different test. Symptoms are intermittent and rerun-green, which is the worst kind of failure to chase. The containment options, from most preferable to least: 1. **Make shared state immutable after `@BeforeAll`.** A parsed schema, a started server handle, a configured client — read by many threads, mutated by none. Publish it safely (assignment in `@BeforeAll` before tests start is fine; Jupiter's happens-before around the hook covers it). 2. **Move mutable state out of fields.** Construct it inside the test method as a local, so nothing is shared at all. 3. **Reset it in `@BeforeEach`.** Works for order dependence, but *not* for parallelism: with concurrent methods, one test's reset can wipe another's in-flight state. Use this only together with serialization. 4. **Serialize the class**: `@Execution(ExecutionMode.SAME_THREAD)` on the class forces its methods onto one thread, restoring a sequential world inside the class while the rest of the suite still parallelizes. 5. **Declare `@ResourceLock`** with a key naming the shared resource (`@ResourceLock("user-repo")`, or the built-in `Resources.SYSTEM_PROPERTIES`, `Resources.SYSTEM_OUT`, etc.). Jupiter then guarantees no other test holding a conflicting lock runs at the same time, and it works across classes, not just inside one. ## Failure mode 3: state that was never per-instance anyway `PER_CLASS` gets blamed for problems it did not cause. Static fields, singletons, Spring application contexts cached across classes, `System.setProperty` mutations, `Locale`/`TimeZone` defaults, files on disk and database rows are shared regardless of lifecycle. The reason to mention them is that a per-class class *looks* like it explains the flakiness, so people fix the wrong thing. Verify which state actually carries the contamination before changing the lifecycle. ## Detection, not just repair - **Randomized method order.** Configuring a random order for test methods (Jupiter supports method order strategies, including a random one seeded per run) turns latent coupling into a reproducible failure with a printed seed. - **Run-each-test-alone job.** A periodic CI job that executes methods individually catches "passes only in the suite" dependencies. - **Enable parallel execution in CI even if local runs are sequential** — races that only ever appear on the build machine are the expensive kind. - **Read the class for fields.** In review, a `PER_CLASS` class with a non-final field that is assigned outside `@BeforeAll` deserves a question. ## The design answer The strongest response ends with a rule rather than a patch: **a per-class instance holds only fixtures that are expensive to build and never mutated; everything a test writes to is created fresh per test.** If a piece of state is both expensive and mutable, that is a signal to make the reset cheap (transaction rollback, truncate-and-reseed, container reuse with a per-test namespace) instead of sharing the dirty object. And because the annotation is per class, the blast radius stays visible: someone reading the file sees the opt-out at the top and knows the isolation guarantee has been traded away deliberately.

  • Resetting the shared field in @BeforeEach fixes the ordering problem. Why is it not enough once methods run in parallel?
    Because @BeforeEach runs per test but on the same object. With concurrent methods, test B's reset can execute while test A is midway through using the field, so A observes state being cleared underneath it. Resetting only restores determinism when the methods are serialized — pair it with @Execution(SAME_THREAD) or a @ResourceLock, or eliminate the shared field entirely.
  • How would you prove that the shared instance, rather than the database or a static cache, is the source of a flaky failure?
    Bisect the state. Run the failing method alone and then immediately after each candidate predecessor to find the pair that reproduces it. Then flip the class to the default PER_METHOD lifecycle: if the failure disappears, the carrier is instance state; if it persists, the contamination lives in statics or external systems and the lifecycle is a red herring. Logging the identity hash of the test instance and the field's contents at the start of each test makes the carrier explicit.

saying these in an interview costs you the question

  • Blaming PER_CLASS for contamination that actually lives in static fields, the database or system properties
  • Claiming @BeforeEach resetting is sufficient under parallel method execution
  • Treating an intermittent failure as a flaky-test retry problem instead of shared-state coupling
  • Saying tests should just be run in a fixed declaration order so the dependency is harmless
  • Assuming Jupiter synchronizes access to the test instance for you

context