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 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) 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
  • Declare in the marker, implement in the test — the step signatures with resolved parameters are declared abstract in the marker, where the generator can see them; the concrete ReceiptWriterTest implements them

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.

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​

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