skip to content

How does the choice of test instance lifecycle interact with shared mutable state and test method ordering, and how do you keep PER_CLASS tests deterministic?

level: seniorimportance: should knowfreq 38%

answer

  1. Lifecycle decides if fields are shared
  2. PER_METHOD: order-independent by construction
  3. PER_CLASS: reset mutable state in @BeforeEach
  4. Don't rely on JUnit's default method order
  5. Parallel + PER_CLASS mutable = data race

basics

~20 s

PER_METHOD gives each test a fresh instance, so shared fields can't leak and order doesn't matter. PER_CLASS reuses one instance, so mutated fields persist between tests; to stay safe, reset that state in @BeforeEach and don't let tests depend on order.

solid answer

~50 s

Lifecycle mode decides whether instance fields are shared. PER_METHOD (default) builds a new instance per test, so any field mutation is confined to that test and execution order is irrelevant. PER_CLASS reuses one instance, so a field one test mutates is visible to the next — this is the trap: a test that quietly relies on a predecessor's mutation becomes order-dependent and flaky, especially since JUnit's default method order is deterministic-but-intentionally-non-obvious (MethodOrderer aside, you shouldn't depend on it). To keep PER_CLASS deterministic: keep shared fixtures immutable/read-only after @BeforeAll, and reset any mutable shared state in @BeforeEach (or re-create it) so every test starts from a known baseline. If you genuinely need ordered stages, opt in explicitly with @TestMethodOrder(OrderAnnotation.class) + @Order, and treat it as a conscious design choice. Parallel execution amplifies all of this: shared mutable state under PER_CLASS plus concurrency means data races, so synchronize or avoid sharing.

code

java · 13 lines
java
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class StoreTest {
    private final EmbeddedDb db = new EmbeddedDb(); // immutable handle, shared
    private List<String> staged;                   // MUTABLE shared state

    @BeforeAll void start() { db.start(); }          // once, non-static
    @AfterAll  void stop()  { db.stop(); }

    @BeforeEach void reset() { staged = new ArrayList<>(); } // independence restored

    @Test void a() { staged.add("x"); assertEquals(1, staged.size()); }
    @Test void b() { assertEquals(0, staged.size()); } // passes regardless of order
}

go deeper

for a junior

Understands that fresh-per-test (PER_METHOD) avoids leakage and that PER_CLASS shares state which needs resetting.

for a middle

Can show a concrete order-dependent failure under PER_CLASS and fix it with a @BeforeEach reset; knows ordering is opt-in via @Order.

for a senior

Reasons about immutable-vs-mutable shared fixtures, default-order non-reliance, and the discipline required to keep PER_CLASS deterministic.

for a principal

Establishes suite-wide conventions covering lifecycle defaults, parallel execution, resource locks, and ordering policy to keep large suites fast and deterministic.

## The core coupling: lifecycle ⇄ shared state Whether your tests can share instance-field state is decided entirely by the **lifecycle mode**: - **PER_METHOD (default):** a *new* test-class instance per `@Test`. Each test's instance fields are private to that test. Mutating a field in `testA` cannot be seen by `testB` (it ran on a different object). Therefore **execution order is irrelevant** for instance state — a key property that makes a suite trustworthy. - **PER_CLASS:** *one* instance reused for all `@Test`s. Now instance fields are effectively shared across the class run, so a mutation in `testA` **is visible** to a later `testB`. ## Why shared state + ordering is a trap When state is shared, two failure modes appear: 1. **Hidden order dependence.** `testB` passes only because `testA` happened to set a field first. If the order changes (a refactor, a filter, parallelization, a different JUnit version's ordering), `testB` breaks. Tests should be independent; ordered dependence is an anti-pattern unless explicitly intended. 2. **Flakiness under change.** Because the dependence is implicit, it's invisible in review and surfaces as intermittent CI failures. ### What is JUnit's default order? By default JUnit 5 orders test methods **deterministically but in a way it deliberately does not document as stable** (a hashed, repeatable order — *not* source order, *not* alphabetical). The guidance is: **don't rely on it.** If you need an order, declare it: `@TestMethodOrder(MethodOrderer.OrderAnnotation.class)` with `@Order(n)` on methods (other orderers: `DisplayName`, `MethodName`, `Random`). Ordering is orthogonal to lifecycle — but it only *matters* when state is shared, i.e. under PER_CLASS (or via static state). ## Keeping PER_CLASS deterministic — the recipe 1. **Make the shared fixture immutable after setup.** Build it once in `@BeforeAll`; if nothing mutates it, sharing is safe regardless of order (e.g. a loaded read-only dataset, a started embedded server you only query). 2. **Reset mutable shared state in `@BeforeEach`.** For anything tests mutate, restore a known baseline before each test — clear the list, reset the counter, re-seed the in-memory store. This recovers PER_METHOD-like independence while keeping the expensive once-only parts. 3. **Don't lean on default order.** If a test needs a predecessor's effect, that's a smell; either combine them or make the dependency explicit with `@Order` and document why. 4. **Mind parallelism.** With `junit.jupiter.execution.parallel.enabled=true`, PER_CLASS shared mutable state becomes a genuine **data race** across threads. Either keep shared state immutable, synchronize access, or confine mutable tests to same-thread execution via resource locks (`@ResourceLock`). ## A concrete failure and its fix ```java @TestInstance(Lifecycle.PER_CLASS) class CartTest { private Cart cart = new Cart(); @Test void addsItem() { cart.add("a"); assertEquals(1, cart.size()); } @Test void startsEmpty() { assertEquals(0, cart.size()); } // FLAKY: 1 if addsItem ran first } ``` Fix by resetting: ```java @BeforeEach void fresh() { cart = new Cart(); } // each test starts clean ``` Or just use the default PER_METHOD lifecycle, which gives this for free. ## The judgment call PER_CLASS is a performance/ergonomics optimization (share an expensive immutable fixture; allow non-static hooks). The moment shared state is *mutable*, you've signed up for the discipline of explicit resets and order-independence. The senior instinct: default to PER_METHOD; reach for PER_CLASS only with a clear, mostly-immutable fixture, and never let tests silently depend on each other.

  • Under PER_METHOD, can a test ever be order-dependent on another test's state?
    Not via instance fields — each test gets its own instance. It can still couple through STATIC fields or external shared resources (files, DBs, system properties), so those remain order hazards even in PER_METHOD.
  • Should you rely on JUnit 5's default method execution order?
    No. The default order is deterministic but intentionally not a documented stable contract. If order matters, declare it explicitly with @TestMethodOrder and @Order; otherwise design tests to be order-independent.
  • How does enabling parallel execution change the PER_CLASS calculus?
    Shared mutable instance state is now accessed concurrently across threads, creating data races and non-deterministic failures. Keep shared state immutable, synchronize it, or use @ResourceLock to serialize access to the contended resource.

PER_METHOD is a fresh hotel room per guest; PER_CLASS is one room reused all week — fine for the fixed furniture, but you must have housekeeping (@BeforeEach) wipe the desk between guests or someone inherits the last guest's mess.

saying these in an interview costs you the question

  • Assuming the default method order is a stable, documented contract you can depend on
  • Using PER_CLASS with mutable shared state but no @BeforeEach reset
  • Believing PER_METHOD eliminates ALL coupling (it doesn't cover static or external state)
  • Ignoring that parallel execution turns shared mutable state into a data race

context