JUnit Extension
JUnit Extension
krandom-junit ships a JUnit Jupiter extension that fixes the kRandom seed per test and reports it
when a test fails, so a failure caused by random data is always reproducible.
testImplementation("io.github.frikit:krandom-junit:2.10.0")
krandom-junit is built and tested against JUnit 6 (Jupiter 6.1) and exposes junit-jupiter-api
as an API dependency; JUnit 5 is not supported. A project that stays on JUnit 5, for example
through Spring Boot 3.5 dependency management, can still pass a seeded GeneratorConfig from
krandom-core to its generators.
Seed reporting on failure
Register the extension and inject a seeded Krandom session, the object that returns everyday
fixture values, into any test:
import io.github.frikit.krandom.generator.Krandom;
import io.github.frikit.krandom.junit.KrandomExtension;
@ExtendWith(KrandomExtension.class)
class CheckoutTest {
private Customer customer;
@BeforeEach
void createCustomer(Krandom fake) {
customer = new Customer(fake.fullName(), fake.email());
}
@Test
void refusesAnotherEmail(Krandom fake) {
// The session of createCustomer: this draws the next email, it does not start the stream again.
assertFalse(checkout.accepts(customer, fake.email()));
}
}
To generate objects, or to use a generator the session does not cover, inject the seeded
GeneratorConfig instead:
import io.github.frikit.krandom.generator.GeneratorConfig;
import io.github.frikit.krandom.generator.Generators;
import io.github.frikit.krandom.junit.KrandomExtension;
@ExtendWith(KrandomExtension.class)
class OrderServiceTest {
@Test
void totalsAreNonNegative(GeneratorConfig config) {
Order order = Generators.ofObject(Order.class, config).generate();
assertTrue(service.total(order).signum() >= 0);
}
}
Every test runs with a concrete per-test seed. When a test fails, the extension:
- publishes the seed as a JUnit report entry under the
krandom.seedkey, and - publishes a redacted portable recipe under the
krandom.recipekey, and - prints a reproduction hint, the source of the recorded clock instant, and copyable Gradle and
Maven replay commands to
System.err:
krandom: test 'totalsAreNonNegative(GeneratorConfig)' failed with seed 8155926046530546882. Annotate it with @KrandomSeed(8155926046530546882L) to reproduce this run.
krandom: recipe clock 2026-09-27T08:25:06.360709Z (Europe/London) was read when the failure was reported; the test ran with a live clock, so time-relative data may not replay exactly. Set krandom.junit.snapshot-clock=true for time-sensitive tests.
Replay with Gradle: KRANDOM_JUNIT_RECIPE=base64:... ./gradlew test --tests 'com.example.OrderServiceTest' --rerun
Replay with Maven: mvn test -Dtest='com.example.OrderServiceTest' -Dkrandom.junit.recipe=base64:...
Replay recipe:
format=krandom-recipe
...
Replay without editing the test source
Copy the printed command when rerunning a CI failure. Both commands carry the complete, redacted
recipe as a shell-safe URL-safe Base64 value and select the failing test’s top-level class
(@Nested classes run through it):
# Gradle: environment variables reach the forked test JVM; --rerun runs an up-to-date test task.
KRANDOM_JUNIT_RECIPE=base64:PASTE_THE_REPORTED_VALUE ./gradlew test --tests 'com.example.OrderServiceTest' --rerun
# Maven: Surefire forwards -D user properties to the forked test JVM.
mvn test -Dtest='com.example.OrderServiceTest' -Dkrandom.junit.recipe=base64:PASTE_THE_REPORTED_VALUE
Do not use ./gradlew test -Dkrandom.junit.recipe=...: Gradle’s -D sets a property on the Gradle
JVM, not on the forked test JVM, so the value never reaches the extension. In a multi-project
Gradle build, run the owning project’s task (./gradlew :orders:test --tests ...) so projects
without a matching test do not fail the filter; in a multi-module Maven build, add
-pl <module> or -Dsurefire.failIfNoSpecifiedTests=false.
For a seed-only replay, use a decimal signed 64-bit value:
KRANDOM_JUNIT_SEED=8155926046530546882 ./gradlew test --tests 'com.example.OrderServiceTest' --rerun
mvn test -Dtest='com.example.OrderServiceTest' -Dkrandom.junit.seed=8155926046530546882
Where settings are read
krandom.junit.recipe, krandom.junit.seed, krandom.junit.class-recipe, and
krandom.junit.snapshot-clock are read as JUnit Platform configuration parameters, so any of these
sources works:
- a JVM system property of the test JVM (Maven
-D, SurefiresystemPropertyVariables, or Gradletasks.test { systemProperty("krandom.junit.seed", "42") }), - a launcher configuration parameter, or
- an entry in
src/test/resources/junit-platform.properties.
When none of them is set, the matching environment variable is used: KRANDOM_JUNIT_RECIPE,
KRANDOM_JUNIT_SEED, KRANDOM_JUNIT_CLASS_RECIPE, or KRANDOM_JUNIT_SNAPSHOT_CLOCK. Configure
only one effective replay value: a recipe and a seed together, from any sources, are a configuration
error; a class recipe combines with either. Replay values override @KrandomSeed, so a CI runner
can reproduce a failure without changing the test source.
Recipes retain a textual seed internally when one was used, so injected GeneratorConfig values
preserve that metadata. Failure output and report entries deliberately omit the original text and
contain only the derived numeric seed. Literal recipe text is also accepted for local tooling when
its line breaks are represented as \n.
Pinning a seed with @KrandomSeed
@KrandomSeed pins the seed for a test method or a whole test class. It also registers the
extension by itself, so no separate @ExtendWith is needed:
import io.github.frikit.krandom.junit.KrandomSeed;
@KrandomSeed(8155926046530546882L)
@Test
void totalsAreNonNegative(GeneratorConfig config) {
// exact replay of the failing run
}
Rules:
- A method-level annotation overrides a class-level one;
@Nestedclasses inherit the enclosing class’s seed. - String seeds are supported via
@KrandomSeed(text = "checkout-flow")and derive the numeric seed with the samefnv1a64-v1algorithm asGeneratorConfig.builder().seed(String). - Setting both
valueandtext(or neither) is a configuration error and fails the test.
Injectable parameters
| Parameter type | Resolves to |
|---|---|
Krandom |
The test’s one session, built from its GeneratorConfig |
GeneratorConfig |
A config pre-seeded with the test’s seed |
GeneratorConfig.Builder |
A builder copied from the test’s recipe/config, for adding locale or other options before build() |
All injections within one test share the same seed, so two injected configs generate identical
sequences. @KrandomSeed, replay overrides and recipes therefore reach the session too, and the seed
in a failure report reproduces the session’s values.
Every Krandom parameter of one test is the same instance, so its streams continue from one method
to the next. Krandom.of(config) builds a new session, whose streams start again: it draws what the
injected session drew, from the first value. Inject the session wherever a test needs it rather than
building it again in a helper.
@Test
void localizedFixture(GeneratorConfig.Builder builder) {
GeneratorConfig config = builder.locale(Locale.GERMANY).build();
String city = Generators.ofCity(config).generate();
}
The injected session uses the test’s configuration as it stands. For a session with other options, build it once from the injected builder and keep it for the test:
private Krandom fake;
@BeforeEach
void createSession(GeneratorConfig.Builder builder) {
fake = Krandom.of(builder.locale(Locale.UK).build());
}
Test-method and class-scoped configurations
Where a parameter is declared decides which configuration it receives:
| Declared on | Receives |
|---|---|
a @Test/@ParameterizedTest method, @BeforeEach/@AfterEach |
the test method’s configuration |
the test-class constructor (default PER_METHOD lifecycle) |
the test method’s configuration, so fields initialised in the constructor match the test’s seed |
@BeforeAll/@AfterAll methods, or the constructor under @TestInstance(PER_CLASS) |
one class-scoped configuration per test class |
The class-scoped configuration uses the class-level @KrandomSeed (searching enclosing classes for
@Nested tests), a replay override, or a random seed. It is shared by that class’s class-level
callbacks and never leaks into a test method: every test resolves its own seed, so a method-level
@KrandomSeed always applies. When a test fails, the failure output also lists the seed of every
class-scoped configuration of its enclosing classes, and the replay commands restore them through
KRANDOM_JUNIT_CLASS_RECIPE (Maven: -Dkrandom.junit.class-recipe), so data created in
@BeforeAll replays together with the test’s own data:
KRANDOM_JUNIT_CLASS_RECIPE='com.example.OrderServiceTest=base64:...' KRANDOM_JUNIT_RECIPE=base64:... ./gradlew test --tests 'com.example.OrderServiceTest' --rerun
The value lists <test class>=base64:<recipe> entries separated by commas, one for each enclosing
class with a class-scoped configuration, keyed by canonical class name (com.example.OuterTest.Inner
for a @Nested class). A listed class’s class-scoped configuration uses its entry instead of the
other replay overrides or @KrandomSeed; test methods and unlisted classes are unaffected. To pin
the data in source instead, annotate each class with its reported class-scoped seed and keep the
test’s own @KrandomSeed.
Sessions in each scope
A Krandom parameter follows the table above: each configuration has one session, built from it when
first injected.
| Declared on | Receives |
|---|---|
the constructor (default PER_METHOD lifecycle), @BeforeEach, the test method and @AfterEach of one test |
one session, whose streams continue from one method to the next |
| any other test | another session; a test neither continues nor advances the streams of another |
a @RepeatedTest repetition or a @ParameterizedTest invocation |
a session of its own, as each has its own configuration |
a @Nested test, including the constructors and @BeforeEach/@AfterEach methods of enclosing classes that run for it |
one session, as for any other test |
@BeforeAll/@AfterAll methods, or the constructor under @TestInstance(PER_CLASS) |
one class-level session per test class, so a @Nested class has its own |
The class-level session and a test’s session are always separate instances. A test does not receive
the class-level session and does not advance it, so its streams continue from @BeforeAll to
@AfterAll whatever the tests drew in between. How their values relate depends on their seeds:
- Without a pin, the class-level session and each test’s session have separate random seeds. A failure reports these seeds, and the printed replay commands restore them.
- A class-level
@KrandomSeedapplies to both, as does a seed or recipe override, so they have one seed and start the same streams. The firstfake.email()of a test is then the firstfake.email()of@BeforeAllagain. For the same reason every test under the pin draws the same values, as does every repetition and invocation, and a@Nestedclass that inherits the pin repeats the class-level values of its enclosing class. A method-level@KrandomSeedgives one test a seed of its own.
When tests must not repeat the values created in @BeforeAll, for example under a unique constraint,
derive a session with streams of its own there:
@BeforeAll
static void createSharedCustomer(Krandom fake) {
Krandom shared = Krandom.of(fake.streamConfig("shared-fixtures"));
sharedCustomer = new Customer(shared.fullName(), shared.email());
}
Parallel execution
A session is not thread-safe, and tests running at the same time do not share one: each test context
has its own, including methods under @Execution(CONCURRENT) and repeated or parameterized invocations.
The @BeforeEach, test and @AfterEach methods of one test run one after another, as do a class’s
@BeforeAll methods, PER_CLASS constructor and @AfterAll methods, and @Nested classes running at
the same time have separate class-level sessions.
A session is shared only if your code passes it on, and these uses are not safe:
- a class-level session kept in a field and used by test methods that run concurrently;
- a
@TestFactorymethod’s session captured by dynamic tests that run concurrently, because the factory method and its@BeforeEachand@AfterEachmethods have one session and dynamic tests get no other; - one session used from several threads that a test starts.
Give each thread a session of its own instead, for example Krandom.of(fake.streamConfig("worker-" + i)).
Composing with the Spring Boot starter
JUnit extensions stack, so the extension combines with the starter’s @KrandomTest slice: keep
the auto-configured beans for application wiring and use the injected GeneratorConfig
parameter for per-test deterministic fixtures.
@KrandomTest
@ExtendWith(KrandomExtension.class)
class UserFixtureTest {
@Autowired
KrandomObjectFakerFactory factory;
@Test
void generateUser(GeneratorConfig config) {
// factory-driven beans + a per-test reproducible config
}
}
See also: Testing Integrations and Migration from Instancio.
Captured test clocks (2.3+)
By default injected configurations keep a live clock, and the failure recipe records the clock
instant at which the failure was reported, not the instant at which the test generated its data.
The failure output says so (was read when the failure was reported). Time-relative values such as
future(7) dates or ages can therefore differ on replay when the test ran for a while or crossed a
day boundary. An injected session’s today() and birthday(...) follow the same clock.
For time-sensitive tests, set krandom.junit.snapshot-clock=true to capture one clock per
configuration before parameter injection. The injected configuration and the failure recipe then
use the same instant and zone, and the failure output states was captured before parameter
injection. The default false retains live clocks; other values are rejected. Set it once in
src/test/resources/junit-platform.properties:
krandom.junit.snapshot-clock=true
or per build: Gradle tasks.test { systemProperty("krandom.junit.snapshot-clock", "true") }, Maven
Surefire systemPropertyVariables, or the KRANDOM_JUNIT_SNAPSHOT_CLOCK=true environment variable.
The extension cannot track later edits to an injected builder. If you change its clock, rules,
locale, or other settings, capture/report the resulting configuration yourself. A seed alone does
not reproduce a moving clock. A replay-recipe override already has a fixed clock, which the output
reports as was fixed by the replay recipe.