From tdder
Guides applying JUnit's @Nested classes and @RegisterExtension to build declarative scenario trees for layered test preconditions, expensive shared setup, and complex integration tests.
How this skill is triggered — by the user, by Claude, or both
Slash command
/tdder:nested-fixture-patternThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A pattern combining JUnit's `@Nested` classes, `@RegisterExtension`, and `ExtensionContext.Store`
A pattern combining JUnit's @Nested classes, @RegisterExtension, and ExtensionContext.Store
to build declarative scenario trees where each nesting level adds a scope with fixture-managed data.
Tests focus on what's specifically relevant to them; expensive setup/teardown happens once per scope;
any subtree runs in isolation.
For background, rationale, and tradeoffs, see the blog post.
Given... class names can meaningfully describe each level@BeforeAll or @BeforeEachWhen you detect layered test setup (2+ levels of dependent @BeforeAll/@BeforeEach, or test classes
with complex shared state), use AskUserQuestion:
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.
class DocumentSharingScenarioTest {
@RegisterExtension static ServerFixture server = new ServerFixture();
@Nested class GivenUserAlice {
@RegisterExtension static UserFixture alice = server.createUser("alice");
@Test void seesEmptyDocumentList() {
then(alice.listDocuments()).isEmpty();
}
@Nested class GivenDocument {
@RegisterExtension static DocumentFixture doc =
alice.createDocument("notes.txt", "hello world");
@Test void isVisibleToAlice() {
then(alice.getDocument(doc.id()))
.hasName("notes.txt")
.hasContent("hello world");
}
@Nested class GivenSharedWithBob {
@RegisterExtension static UserFixture bob = server.createUser("bob");
@RegisterExtension static ShareFixture share =
doc.shareTo(bob, Permission.READ);
@Test void bobCanRead() {
then(bob.getDocument(doc.id()))
.hasContent("hello world");
}
@Test void bobCannotWrite() {
assertThatThrownBy(() ->
bob.updateDocument(doc.id(), "modified"))
.isInstanceOf(ForbiddenException.class);
}
}
}
}
}
Each fixture implements BeforeAllCallback and guards setup with computeIfAbsent:
class UserFixture implements BeforeAllCallback {
private final ApiClient apiClient;
private final String name;
private String id;
UserFixture(ApiClient apiClient, String name) {
this.apiClient = apiClient;
this.name = name;
}
@Override public void beforeAll(ExtensionContext context) {
context.getStore(GLOBAL).computeIfAbsent(this, k -> {
id = apiClient.createUser(name);
return (AutoCloseable) () -> apiClient.deleteUser(id);
});
}
String userId() {return id;}
}
The computeIfAbsent lambda returns an AutoCloseable that fires when the declaring
context ends. The fixture holds state; the store holds the cleanup handle.
JUnit 5 vs 6: JUnit 5 uses
getOrComputeIfAbsentandCloseableResource. JUnit 6 usescomputeIfAbsentandAutoCloseable.
Fixtures naturally become the access point for everything they set up. Tests and nested classes call methods on the fixture directly rather than reaching into its fields:
class ServerFixture implements BeforeAllCallback {
private String baseUrl;
private ApiClient client;
@Override public void beforeAll(ExtensionContext context) {
context.getStore(GLOBAL).computeIfAbsent(this, k -> {
// ... start server ...
baseUrl = "http://localhost:" + port;
client = new ApiClient(baseUrl);
return (AutoCloseable) () -> server.stop();
});
}
ApiClient client() { return client; }
String baseUrl() { return baseUrl; }
UserFixture createUser(String name) { return new UserFixture(this, name); }
}
This applies to any state accumulated during setup: injected clients, auth tokens, base URLs, created resource IDs. Expose them as methods; don't make callers reach into fields.
Parent fixtures can create child fixtures via factory methods. The parent wires context (API clients, auth tokens, resource IDs) so the child declaration stays clean:
static ServerFixture server = new ServerFixture();
static UserFixture alice = server.createUser("alice");
static DocumentFixture doc = alice.createDocument("notes.txt", "hello world");
Only write a fixture class when there is teardown to manage, or when the same setup
is shared across multiple sibling @Nested classes. If neither applies, a @BeforeAll
method in the nested class is simpler and equally correct.
In that case, the parent fixture can expose plain action methods that return domain values
directly, rather than fixture objects. The @Nested class calls them from @BeforeAll:
class TaskFixture implements BeforeAllCallback {
Task task;
// lifecycle-managed: needs teardown -> fixture class
// (createTask registered in computeIfAbsent, deleteTask in AutoCloseable)
// no teardown, not shared across siblings -> plain method
Task complete() { return client().completeTask(task.id); }
boolean delete() { return client().deleteTask(task.id); }
}
@Nested class GivenTaskIsCompleted {
static Task completed;
@BeforeAll static void completeTask() {
completed = task.complete(); // no fixture class needed
}
...
}
beforeAll. Actual work happens
inside computeIfAbsent.@RegisterExtension fields in source order.
A fixture that references another must be declared after it.GLOBAL namespace and this as the store key: this is identity-based,
so each fixture instance is independent. Domain values (e.g. a user name) would alias
independent fixtures with the same value; types (e.g. UserFixture.class) would alias
all fixtures of that type. GLOBAL is sufficient because this is already unique.
Never override equals/hashCode on fixtures — the store relies on identity.@Nested class destroys
the shared resource (e.g. deletes a task), it must declare its own fixture rather than
sharing the parent's. Otherwise, sibling nested classes that depend on the same resource
will fail non-deterministically depending on test execution order.beforeAll: field initializers run at
class-load time, before any injection framework (CDI, Spring, etc.) has populated beans.
Never pass an injected object as a constructor argument to a fixture. Instead, look it
up lazily inside computeIfAbsent. With Quarkus/CDI:
@Override public void beforeAll(ExtensionContext context) {
context.getStore(GLOBAL).computeIfAbsent(this, k -> {
var client = Arc.container().instance(MyClient.class).get();
// use client ...
});
}
npx claudepluginhub t1/tdder --plugin tdderImplements, configures, and debugs JUnit extensions including custom extensions, rules, and conditional test execution in Java projects.
Guides use of Playwright built-in and custom fixtures for page, context, browser, and request to manage test state and infrastructure with efficient setup and teardown.
Guides TestNG setup with Maven/Gradle, lifecycle annotations (@BeforeSuite to @AfterMethod), assertions, and XML configuration for Java testing.