skip to content

Your JUnit 5 suite is slow because many test classes rebuild an expensive fixture — a started service, a large parsed document, a warmed client — before every test method. How would you decide between keeping the default one-instance-per-test-method lifecycle and moving classes to @TestInstance(PER_CLASS), and what guardrails would you require either way?

level: principalimportance: should knowfreq 30%

answer

  1. measure first — construction is rarely the cost
  2. immutability test for shared fixtures
  3. reset (rollback) beats rebuild beats share
  4. per class or composed annotation, not a global flip
  5. random order + run-alone + parallel in CI

basics

~20 s

First check whether the cost is really per-instance; often it belongs in a fixture that can be built once and shared read-only. Move only classes whose expensive state is immutable after setup, keep everything mutable per test, and require guardrails: randomized method order, a run-each-test-alone job, and parallel execution enabled in CI.

solid answer

~60 s

I treat the default as a **safety invariant** and any opt-out as a scoped, justified exception. First, measure where the time actually goes — often it is not instance construction but a container start, a context load, or unnecessary work in `@BeforeEach`. Several fixes come before the lifecycle: build the fixture once behind an extension or a shared resource, reuse a container across classes, replace a real dependency with an in-memory one, or reset cheaply (transaction rollback) rather than rebuild. If the lifecycle is genuinely the lever, I apply the **immutability test**: a class qualifies for `PER_CLASS` only if the expensive state is never mutated by a test. Anything a test writes to stays per-test. Classes that fail the test get a cheap reset instead of a shared object. Adoption is per class (or via a composed `@IntegrationTest` annotation), never a global default flip, so the decision remains visible where it applies. Guardrails: randomized method order, a periodic run-each-test-individually job, method-level parallelism enabled in CI, and a review rule that a mutable field in a `PER_CLASS` class needs justification.

code

java · 14 lines
java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Execution(ExecutionMode.SAME_THREAD)
public @interface HeavyFixtureTest {
}

@HeavyFixtureTest
class SchemaValidationTest {
    private Schema schema;                 // immutable after setup

    @BeforeAll
    void compile() { schema = Schema.parse(bigFile()); }
}

go deeper

for a junior

Know the trade being made: shared instance means faster setup but tests can affect each other, so the default exists for a reason.

for a middle

Argue from the fixture's mutability — share only what is immutable after @BeforeAll — and prefer resetting cheaply over sharing dirty state.

for a senior

Profile first, exhaust cheaper levers (extension-scoped fixtures, transaction rollback, fakes), scope the opt-out per class, and add order-randomization and run-alone detection.

for a principal

Set policy: the per-method default is an isolation invariant, opt-outs are narrow and evidenced, adoption is via a named composed annotation, and the change is reverted if the measured win does not justify the standing risk.

## Frame the decision correctly The default lifecycle — a fresh test instance per test method — is not a performance setting, it is an **isolation invariant**. It is what makes a suite reproducible, filterable, shardable and safely parallel. So the question is not "per-method or per-class?" but "what am I willing to pay for speed, and where does the bill land?" A principal-level answer starts by refusing to trade the invariant globally and then negotiates narrow, evidenced exceptions. ## Step 1: find where the time actually goes Before touching the lifecycle, get numbers. Instance construction of a plain Java object is free; what is expensive is what setup *does*. Typical culprits, in rough order of frequency: - Starting a container, embedded database, or HTTP server per test. - Loading an application context per class (or worse, per test, because a mutation dirtied the cached context). - Parsing/compiling something large: a schema, a grammar, a big fixture file. - Redundant work in `@BeforeEach` that only needs to happen once. - Real I/O, sleeps and polling that could be replaced by a fake or a deterministic clock. Often the profile shows the lifecycle is irrelevant — the cost is a per-class container or a context that keeps getting evicted. Changing `@TestInstance` would then buy nothing and cost isolation. ## Step 2: exhaust the cheaper levers Several options preserve isolation entirely: - **Share the expensive thing above the class**, through a JUnit extension with a store at the root scope, or a singleton container started once per JVM. The fixture is created once for many classes; each test still gets its own instance. - **Reset instead of rebuild.** Wrapping each test in a transaction that rolls back, truncating and reseeding, or creating a per-test schema/namespace is usually far cheaper than a rebuild and keeps tests independent. - **Substitute.** An in-memory implementation, a stubbed HTTP server, or a fake clock removes the cost rather than amortizing it. - **Parallelize.** If per-test cost is irreducible, spreading classes across threads or CI shards may be the cheaper win — but note that per-class instances make parallelism harder, so this lever and the lifecycle lever pull against each other. ## Step 3: the immutability test When the lifecycle really is the lever, apply one rule: **a class may hold state in a shared instance only if that state is never mutated by a test.** A compiled template, a started server handle, a configured client, a parsed schema — read-only after `@BeforeAll` — are ideal. Anything a test writes to (repository contents, captured events, counters, mock interactions) stays per-test: created in `@BeforeEach` or as a local in the test method. A class that cannot pass this test should not become `PER_CLASS`. It should get a cheap reset path instead. If the reset is not cheap, that is a design problem in the fixture, not a reason to share dirty state. ## Step 4: scope the change so it stays visible Adopt per class, not per suite. The `junit.jupiter.testinstance.lifecycle.default` configuration parameter can flip the whole run, but it moves a safety decision into a properties file nobody opens, and it retroactively changes classes written under different assumptions. Instead: - Annotate the specific classes, or - Create a **composed annotation** (`@HeavyFixtureTest` = `@TestInstance(PER_CLASS)` + whatever else those tests need) so the intent is named, documented in one place and greppable, while still appearing at the top of each file. The global flip is defensible in one situation worth conceding: a Kotlin codebase where every static `@BeforeAll` needs a `companion object` with `@JvmStatic`. Even then, pair it with the guardrails below. ## Step 5: guardrails, whichever way you go - **Randomized method order** in CI. Any surviving order dependence fails with a reproducible seed instead of hiding until an unrelated change reorders the suite. - **A run-each-test-individually job**, periodically. Catches tests that only pass because a predecessor ran. - **Method-level parallel execution enabled somewhere.** Races on shared instances then surface during development rather than after someone enables parallelism to fix the very slowness you were addressing. - **Review rule**: a non-final, test-mutated field in a `PER_CLASS` class requires a comment justifying it. - **Budget the outcome**: record suite wall-clock before and after. If the lifecycle change did not measurably help, revert it — you are holding isolation debt for nothing. ## How to close the answer Say explicitly what you are optimizing. Test-suite speed matters because it sets the feedback loop, but flakiness is more expensive than slowness: a slow suite costs minutes, an order-dependent suite costs trust and hours of debugging, and once trust goes, people start rerunning failures instead of reading them. So the policy is narrow, evidenced opt-outs with detection in place — not a suite-wide default flip that trades a measurable win for an unmeasured risk.

  • When would you prefer a JUnit extension holding the fixture in a root-scoped store over PER_CLASS with an instance field?
    When the expensive resource should be shared across many test classes rather than within one — a container, an embedded broker, a compiled artifact. An extension can create it once per run, store it at the root scope so every class reuses it, and close it when the run ends, all while each test class keeps the default per-method isolation. PER_CLASS only amortizes cost within a single class, so it is the weaker lever when the fixture is used broadly.
  • Suppose moving thirty classes to PER_CLASS cuts the suite from 9 to 8 minutes. What do you do?
    Revert it. A one-minute saving does not justify holding an isolation exception in thirty files, each of which is now a place where a future test can silently become order-dependent. I would take the measurement as evidence that instance construction was never the bottleneck and go back to the profile to find the real cost — most likely container startup, context reloads, or I/O that a fake could replace.
  • Your team wants to enable method-level parallel execution to speed things up, and half the suite is already PER_CLASS. How do you sequence that?
    Enable parallelism at class level first, which is safe for per-class instances since each class still runs on one thread. Then, before enabling method-level concurrency, audit the PER_CLASS classes: those whose shared state is immutable after setup can opt in, the rest get @Execution(SAME_THREAD) or @ResourceLock, or are converted back to the default lifecycle. Turning method-level concurrency on globally first would produce a wave of intermittent failures with no clear owner.

A shared workshop bench: fine for the heavy vise bolted to it that nobody moves, wrong for the pile of parts each person is actively rearranging.

saying these in an interview costs you the question

  • Flipping the suite-wide default lifecycle as a performance measure without measuring where the time goes
  • Sharing mutable fixture state and relying on test order to keep it consistent
  • Claiming PER_CLASS is generally faster, when instance construction itself is usually negligible
  • Ignoring that per-class instances complicate the parallel execution that could have delivered the real speedup
  • Treating resulting flakiness as a retry-configuration problem rather than the isolation debt it is

context