Skip to main content

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) and TestInfo (test metadata) need no marker; the processor recognises them by type
  • Custom resolver — a Clock filled by FixedClockResolver is opted in with @JUnitResolved on the parameter
  • Mixing on a single step — one step method takes a Gherkin-derived String plus a built-in @TempDir Path plus a custom @JUnitResolved Clock
  • Aggregation across steps — the generated scenario_1 method declares the union of every resolved parameter required by any of the scenario's steps, deduplicated by name
  • Annotation passthrough@TempDir is preserved on the generated parameter so JUnit's resolver fires; @JUnitResolved is a SpecBinder-internal marker and is stripped

Two recognition rules

Parameter categoryRecognitionExample
Built-in JUnit type (implicit)Recognised by type — and by the @TempDir annotation for the Path / File variantsTestInfo 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

PlacementWhen to useExample
On the parameterAlways works — required for JDK types and third-party types you can't modifyvoid user(@JUnitResolved Clock clock)
On the type's class declarationConvenient 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