Popular searches
//

Nested Fixture Pattern for JUnit

9.3.2026 | 11 minutes reading time

JUnit's @Nested classes are usually presented as a way to group related tests. But combined with @RegisterExtension and ExtensionContext.Store, they become something more powerful: a declarative scenario tree where each level adds a scope in which fixtures take care of the necessary data. This allows your tests to focus on the things specifically relevant to them. Potentially expensive or complex setup/teardown of test data will happen only when necessary, while you still can run any subtree in isolation. This is most useful when tests need to work with shared state that takes a long time to set up, like running servers, provisioned users, or seeded databases.

We've been using variants of this pattern in production for over two years. A sample project and other validating tests are here. We haven't seen it documented anywhere, so here it is.

The Typical Problem

You're testing a REST API with layered preconditions. A server needs to be running before you can create users. Users need to exist before they can create documents. Documents need to exist before you can test sharing and permissions. And you want to test behavior at every level, not just at the leaves.

The naïve approaches all have problems:

Manual setup in each test: Expensive operations run repeatedly. Test methods become walls of setup code with the actual assertion buried at the bottom. The natural fix is to extract helpers: createUserWithDocument(), setupShareScenario(). But these helpers grow. They accumulate parameters. They start calling each other. Before long you have a TestHelper class with 1000+ lines, a web of methods that create preconditions in slightly different combinations, and no clear way to see which test depends on which setup path.

@BeforeAll monsters: You end up with one giant setup method or a chain of methods with ordering dependencies. Want to run just the tests for the newly created user? You still run all the setup for documents and shares. Want to see what preconditions a test assumes? Read through the setup code and mentally track what's been initialized.

@Ordered waterfalls: The shared state produced by an earlier test method is required for the later ones. In order to run one test, you'll have to run all before. And cleanup is not guaranteed.

What we want:

  1. Preconditions are explicit and named: you read the class name and know what's set up
  2. Lifecycle is scoped: a fixture sets up when entering its level, tears down when leaving
  3. Setup runs once per scope: expensive operations aren't repeated for each test
  4. Any subtree is independently runnable: run just the nested class or even single test you care about

The pattern

Here's what it looks like. Imagine testing a document management API:

1class DocumentSharingScenarioTest {
2    @RegisterExtension static ServerFixture server = new ServerFixture();
3
4    @Nested class GivenUserAlice {
5        @RegisterExtension static UserFixture alice = server.createUser("alice");
6
7        @Test void seesEmptyDocumentList() {
8            then(alice.listDocuments()).isEmpty();
9        }
10
11        @Nested class GivenDocument {
12            @RegisterExtension static DocumentFixture doc =
13                    alice.createDocument("notes.txt", "hello world");
14
15            @Test void isVisibleToAlice() {
16                then(alice.getDocument(doc.id()))
17                        .hasName("notes.txt")
18                        .hasContent("hello world");
19            }
20
21            @Test void isListedToAlice() {
22                then(alice.listDocuments()).containsExactly(doc.id());
23            }
24
25            @Nested class GivenSharedWithBob {
26                @RegisterExtension static UserFixture bob = server.createUser("bob");
27
28                @RegisterExtension static ShareFixture share =
29                        doc.shareTo(bob, Permission.READ);
30
31                @Test void bobCanRead() {
32                    then(bob.getDocument(doc.id()))
33                            .hasContent("hello world");
34                }
35
36                @Test void bobCannotWrite() {
37                    assertThatThrownBy(() ->
38                            bob.updateDocument(doc.id(), "modified"))
39                            .isInstanceOf(ForbiddenException.class);
40                }
41            }
42        }
43    }
44}

Read this as a tree:

DocumentSharingScenarioTest        → server started
├── GivenUserAlice                 → + alice created
│   ├── seesEmptyDocumentList
│   └── GivenDocument              → + document created
│       ├── isVisibleToAlice
│       ├── isListedToAlice
│       └── GivenSharedWithBob     → + bob created, share granted
│           ├── bobCanRead
│           └── bobCannotWrite

Each @Nested class is a Given clause. Each @RegisterExtension static field is a fixture that sets up when entering that class and tears down when leaving. The nesting gives you the precondition hierarchy for free.

A note on code style.

The examples use a few conventions that aren't part of the pattern itself, but may be unexpected:

  • then() is a custom AssertJ entry point, just a static import for readability.
  • Test classes and methods are package-private (no public), which is the JUnit 5 convention.
  • Short accessor methods like String userId() {return id;} use single-line formatting to reduce visual noise.
  • We use static imports whenever the context is clear, i.e. just GLOBAL (see below).

The piece that makes it work: Store + computeIfAbsent

There's a catch. When you register an extension as a static field on an outer class, JUnit fires its BeforeAllCallback for every class in the hierarchy: the declaring class and every @Nested class below it. So your server fixture's beforeAll would run again when entering GivenUserAlice, which is not what you want.

The fix uses JUnit's ExtensionContext.Store. Each fixture's beforeAll wraps its setup in computeIfAbsent, which ensures the work runs only once:

1class UserFixture implements BeforeAllCallback {
2    private final ApiClient apiClient;
3    private final String name;
4    private String id;
5
6    UserFixture(ApiClient apiClient, String name) {
7        this.apiClient = apiClient;
8        this.name = name;
9    }
10
11    @Override public void beforeAll(ExtensionContext context) {
12        context.getStore(GLOBAL).computeIfAbsent(this, k -> {
13            id = apiClient.createUser(name);
14            return (AutoCloseable) () -> apiClient.deleteUser(id);
15        });
16    }
17
18    String userId() {return id;}
19}

The lambda does the setup work, stores the result in the fixture's own field, and returns an anonymous AutoCloseable for teardown. The fixture itself holds the state, and the store just holds the cleanup handle.

This works because of two properties of JUnit's store:

Stores are per-context, not global. Each ExtensionContext has its own store, and close() on an AutoCloseable entry fires when that context ends, i.e. exactly when the declaring class's scope exits.

A note on GLOBAL

GLOBAL is a namespace, not a scope: it controls key isolation, not the lifecycle. I.e. extensions using the same generic key (like Connection.class) would collide in the same namespace, but stay independent in separate ones. In our case, this is already globally unique, so GLOBAL is fine.

Stores are hierarchical. A child context's store can see entries from its parent's store. When beforeAll fires for GivenDocument, computeIfAbsent looks up this (the UserFixture instance declared on GivenUserAlice), finds it in the outer class's store, and skips the lambda. The setup runs once, at the declaring level, and is visible to all nested classes below.

Why this as the key?

Using the fixture instance itself (this) as the store key is important. Object.hashCode() defaults to identity, so each fixture instance gets its own store entry; even two UserFixture instances within the same class will be independent. The hierarchical lookup ensures a fixture's entry is found by nested classes without re-creating.

You might be tempted to use a domain value (like the user-name) or a type (like UserInstance.class) as the key. Both cause problems: a domain value would alias two fixtures with the same name that should be independent, and a type would alias all fixtures of that type across the hierarchy.

And this is just the simplest option.

Fixtures as factories

In the example above, parent fixtures create child fixtures:

1static ServerFixture server = new ServerFixture();
2static UserFixture alice = server.createUser("alice");
3static DocumentFixture doc = alice.createDocument("notes.txt", "hello world");
4static UserFixture bob = server.createUser("bob");
5static ShareFixture share = doc.shareTo(bob, Permission.READ);

Why factories instead of direct construction? The parent fixture very often holds context that the child needs: API clients, auth tokens, resource IDs. The factory method wires that context in, so the child fixture's declaration at the test site stays clean.

Note that in GivenSharedWithBob, bob and share are both declared as fields, but share references bob. Declaration order is very important here, as JUnit processes @RegisterExtension fields in the order they appear in the source, so bob must be declared before share. Factory methods like shareTo and the fixtures they produce must only store their parameters: they can't do anything with bob because its beforeAll hasn't set it up yet. The actual work happens later, inside computeIfAbsent. And because the beforeAll of bob runs before the beforeAll of share, the latter can access the former. This reads very natural and is inherent to the pattern: fixtures are constructed at field-initialization time but set up at callback time.

Also note that field initializers run unconditionally when the class is loaded, even if the @Nested class is disabled (e.g. via @Disabled or an IDE selection). If a factory method eagerly reads state from another fixture, you'll get an NPE before JUnit even decides whether to run the class. To avoid this, keep your factories pure: just store parameters.

It's often good to put all tests into one big class, but it doesn't have to be. You could have one top-level test class for every scenario, each with its own root fixture, and still use the same pattern within each. A single class works well when all scenarios share one expensive resource (like a server or a database) that you don't want to re-provision. This also has advantages for executing in parallel (see below).

Compatibility

Java 16+

Before Java 16, inner classes couldn't have static members. Since @Nested classes in JUnit are inner classes (not static nested classes), you couldn't write @RegisterExtension static in them. JEP 395 (Records, final in Java 16) relaxed this restriction as a side effect: inner classes can now have static fields, methods, and member types. That one language change is what unlocked this pattern.

JUnit 5 or 6

The pattern works in both JUnit 5 and JUnit 6 with two small API differences:

Method name. JUnit 5 calls it getOrComputeIfAbsent; JUnit 6 shortened it to computeIfAbsent. Same behavior, different name.

Teardown interface. JUnit 5 only auto-closes stored values that implement CloseableResource, a JUnit-specific interface. JUnit 6 deprecated CloseableResource and uses the standard AutoCloseable instead.

The examples in this post use the JUnit 6 API. If you're on JUnit 5, replace computeIfAbsent with getOrComputeIfAbsent and cast to CloseableResource instead of AutoCloseable.

When to use

This pattern works well when:

  • Multiple, layered preconditions: your test scenarios have 2+ levels of setup that depend on each other, making setup code complex
  • Abstractions are clear: the Given... class names document a scenario that you can understand without having to read the setup code
  • Expensive shared setup: you need shared state that you don't want to set up for every test, and/or need guaranteed cleanup
  • You want to run any subtree in isolation: click on a @Nested class or a single @Test method in your IDE and run just that

When not to use

Where the pattern is not worth the cost:

  • Simple tests with flat preconditions: just use @BeforeAll or @BeforeEach
  • Tests where each method has different setup: use parameterized tests or individual setup
  • Fast, isolated unit tests: the overhead of fixtures and nesting is most often not worth it
  • Precondition hierarchies that are still in flux: refactoring fixtures is more expensive than refactoring flat setup code. The pattern pays off when the hierarchy is stable

It has other drawbacks you should consider:

Learning Curve

You have to understand how this pattern works. New team members will see @RegisterExtension static fields on nested classes and computeIfAbsent calls in fixtures and won't immediately understand how the lifecycle works. The per-context store hierarchy, the this-as-key pattern, the field-init-time vs. callback-time distinction, the factory methods that must not read state. These all need to be learned. Until someone has internalized the pattern, reading a test class like DocumentSharingScenarioTest requires more trust in the abstractions of the fixtures than reading a flat @BeforeAll setup. When writing fixtures, you have to be even more familiar with the details.

This cost is real. It's also front-loaded: once the fixtures are written and the team is familiar with the pattern, it feels natural. But getting there takes time, and during that time you'll field questions about why beforeAll fires multiple times and why fixture fields are null in unexpected places.

Debugging can also be less obvious. When a fixture's setup fails, the stack trace goes through the computeIfAbsent lambda. When a test fails, you need to mentally reconstruct which fixtures are active at that nesting level. IDE support helps: you can click on a @Nested class and run just that subtree. But the indirection is there.

Parallel Execution

Parallel test execution needs care. The pattern relies on shared fixture state across nested classes, so running sibling @Nested classes or test methods concurrently can cause interference... depending on your domain, this may not be an issue, but it could become one. So if you enable JUnit's parallel execution, it may be better to configure it to run top-level classes in parallel but methods and nested classes within a class sequentially.

Summary

The recipe:

  1. Make each fixture implement BeforeAllCallback
  2. In beforeAll, use context.getStore(GLOBAL).computeIfAbsent(this, ...) to guard setup
  3. Return an AutoCloseable lambda from computeIfAbsent for scoped teardown
  4. Declare fixtures as @RegisterExtension static fields at the nesting level where they apply
  5. Use @Nested class Given... to build your scenario tree
  6. Never read fixture state at field-init time, just store the parameters. Do the actual work in computeIfAbsent
  7. Optionally, use factory methods on parent fixtures to wire context into child fixtures

The @Nested tree gives you the structure. @RegisterExtension gives you the lifecycle. Store.computeIfAbsent makes the lifecycle respect the structure. Together, they turn a test class into a declarative specification of your system's sub-scenario space.

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.