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:
- Preconditions are explicit and named: you read the class name and know what's set up
- Lifecycle is scoped: a fixture sets up when entering its level, tears down when leaving
- Setup runs once per scope: expensive operations aren't repeated for each test
- 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
GLOBALis a namespace, not a scope: it controls key isolation, not the lifecycle. I.e. extensions using the same generic key (likeConnection.class) would collide in the same namespace, but stay independent in separate ones. In our case,thisis already globally unique, soGLOBALis 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
@Nestedclass or a single@Testmethod 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
@BeforeAllor@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:
- Make each fixture implement
BeforeAllCallback - In
beforeAll, usecontext.getStore(GLOBAL).computeIfAbsent(this, ...)to guard setup - Return an
AutoCloseablelambda fromcomputeIfAbsentfor scoped teardown - Declare fixtures as
@RegisterExtension staticfields at the nesting level where they apply - Use
@Nested class Given...to build your scenario tree - Never read fixture state at field-init time, just store the parameters. Do the actual work in
computeIfAbsent - 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.
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Blog author
Rüdiger zu Dohna
IT Consulting Expert
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.