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 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
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.
Marker class
@Gherkin2JUnit("specs/ReceiptWriter.feature")
@Gherkin2JUnitOptions(shouldBeAbstract = false)
@ExtendWith(FixedClockResolver.class)
public abstract class ReceiptWriterFeature {
private Path receiptFile;
// Gherkin-derived + built-in @TempDir + custom @JUnitResolved — three sources on one step
public void anOrder$p1WithItemsHasBeenPlaced(
String orderId,
@TempDir Path receiptsDir,
@JUnitResolved Clock clock) throws IOException {
receiptFile = receiptsDir.resolve(orderId + ".txt");
Files.writeString(receiptFile, "Order " + orderId + " issued at " + clock.instant());
}
// Built-in TestInfo only — enriches the failure message
public void theReceiptFileExists(TestInfo testInfo) {
assertTrue(Files.exists(receiptFile),
() -> "no receipt file for test: " + testInfo.getDisplayName());
}
// Custom @JUnitResolved Clock only
public void theReceiptIsTimestampedWithTheTestClock(@JUnitResolved Clock clock) throws IOException {
assertTrue(Files.readString(receiptFile).contains(clock.instant().toString()));
}
}
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 generated test class 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