JUnit Parameter Resolution
Step methods can receive parameters that JUnit fills at test execution time — both JUnit's built-in resolvers (TestInfo, TestReporter, @TempDir) and custom user-registered ParameterResolvers opted in via the @JUnitResolved marker annotation. SpecBinder's annotation processor sees the parameter on the step method declared in the marker class and propagates it through to the generated @Test (or @BeforeEach / @ParameterizedTest) method, where JUnit's resolution kicks in at runtime.
Source: examples/going-further/example-2 on GitHub.
What this demonstrates
- Built-in implicit resolution —
@TempDir Path(per-test temporary directory) andTestInfo(test metadata) need no marker; the processor recognises them by type - Custom resolver — a
Clockfilled byFixedClockResolveris opted in with@JUnitResolvedon the parameter - Mixing on a single step — one step method takes a Gherkin-derived
Stringplus a built-in@TempDir Pathplus a custom@JUnitResolved Clock - Aggregation across steps — the generated
scenario_1method declares the union of every resolved parameter required by any of the scenario's steps, deduplicated by name - Annotation passthrough —
@TempDiris preserved on the generated parameter so JUnit's resolver fires;@JUnitResolvedis a SpecBinder-internal marker and is stripped - Declare in the marker, implement in the test — the step signatures with resolved parameters are declared
abstractin the marker, where the generator can see them; the concreteReceiptWriterTestimplements them
Two recognition rules
| Parameter category | Recognition | Example |
|---|---|---|
| Built-in JUnit type (implicit) | Recognised by type — and by the @TempDir annotation for the Path / File variants | TestInfo testInfo, @TempDir Path dir |
| Custom resolver type (explicit) | Recognised by the @JUnitResolved marker on the parameter, or on the type's class declaration | @JUnitResolved Clock clock |
If the processor encounters a trailing parameter that is neither a built-in nor @JUnitResolved-marked, it treats the base method as a non-match and falls back to emitting a fresh step method. This is what makes accidental extra parameters surface as a "step not implemented" failure rather than silently corrupting the generated call site.
The class hierarchy
ReceiptWriterFeature.java (marker, @Gherkin2JUnit — declares the steps with resolved parameters)
└→ ReceiptWriterScenarios.java (generated, abstract — @Test methods receive the resolved parameters)
└→ ReceiptWriterTest.java (your concrete class, implements the steps)
Marker class: the declarations
The generator only propagates resolved parameters for step methods it finds in the marker's class hierarchy — it can't see a subclass of the generated class. So the steps that take resolved parameters are declared abstract in the marker, with their full signatures. The spec, ReceiptWriter.specb, sits next to the marker in src/test/java, so a bare @Gherkin2JUnit finds it by convention:
@Gherkin2JUnit
@ExtendWith(FixedClockResolver.class)
public abstract class ReceiptWriterFeature {
// Gherkin-derived + built-in @TempDir + custom @JUnitResolved — three sources on one step
public abstract void anOrder$p1WithItemsHasBeenPlaced(
String orderId,
@TempDir Path receiptsDir,
@JUnitResolved Clock clock);
// Built-in TestInfo only — enriches the failure message
public abstract void theReceiptFileExists(TestInfo testInfo);
// Custom @JUnitResolved Clock only
public abstract void theReceiptIsTimestampedWithTheTestClock(@JUnitResolved Clock clock);
}
Concrete test: the implementations
ReceiptWriterTest extends the generated class and implements the steps. Here the resolved values are just ordinary arguments — the parameter annotations only matter on the declarations:
public class ReceiptWriterTest extends ReceiptWriterScenarios {
private Path receiptFile;
@Override
public void anOrder$p1WithItemsHasBeenPlaced(String orderId, Path receiptsDir, Clock clock) {
receiptFile = receiptsDir.resolve(orderId + ".txt");
try {
Files.writeString(receiptFile, "Order " + orderId + " issued at " + clock.instant());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Override
public void theReceiptFileExists(TestInfo testInfo) {
assertTrue(Files.exists(receiptFile),
() -> "no receipt file for test: " + testInfo.getDisplayName());
}
// theReceiptIsTimestampedWithTheTestClock(Clock clock) reads the file back
// and asserts it contains clock.instant()
}
Custom ParameterResolver
A standard JUnit 5 ParameterResolver — nothing SpecBinder-specific about it. Registration is via @ExtendWith(FixedClockResolver.class) on the marker class; because @ExtendWith is @Inherited, the concrete ReceiptWriterTest picks it up automatically.
public class FixedClockResolver implements ParameterResolver {
private static final Clock FIXED = Clock.fixed(
Instant.parse("2024-01-15T10:00:00Z"),
ZoneOffset.UTC);
@Override
public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
return pc.getParameter().getType().equals(Clock.class);
}
@Override
public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
return FIXED;
}
}
Generated @Test method (excerpt)
The processor walks all three steps, collects their resolved parameters, and lists the deduplicated union on the generated scenario_1:
@Test
@Order(1)
@DisplayName("Scenario: Write a timestamped receipt for an order")
public void scenario_1(@TempDir Path receiptsDir, Clock clock, TestInfo testInfo) {
/*
* Given an order "ORD-001" with items has been placed
*/
anOrder$p1WithItemsHasBeenPlaced("ORD-001", receiptsDir, clock);
/*
* Then the receipt file exists
*/
theReceiptFileExists(testInfo);
/*
* And the receipt is timestamped with the test clock
*/
theReceiptIsTimestampedWithTheTestClock(clock);
}
Note: @JUnitResolved is stripped from the generated Clock parameter (it has no JUnit semantics), while @TempDir is preserved so JUnit's built-in resolver fires.
@JUnitResolved placement options
| Placement | When to use | Example |
|---|---|---|
| On the parameter | Always works — required for JDK types and third-party types you can't modify | void user(@JUnitResolved Clock clock) |
| On the type's class declaration | Convenient for your own types that are always JUnit-resolved | @JUnitResolved public class OrderContext { ... } then void user(OrderContext ctx) |
The two are equivalent at detection time. The example uses parameter-level because Clock is a JDK type.
Run it
cd examples/going-further/example-2
mvn test