Skip to main content

Instancio: Generate Realistic Test Data Automatically in Java

Instancio 6.1 fills whole Java object graphs with random data and replays any failure from a seed. Run on JUnit 6.1.3: selectors, Settings, records and sealed types, TypeToken, the extension, and the real seed message from a failing test.

Every test needs a Person. Not every test cares what the Person looks like. A test for “orders over 500 get free shipping” cares about order totals and nothing else, but to build the object you still write a name, an e-mail, an address with four fields, a list of orders, and a payment method. Multiply that by a few hundred tests and the setup code drowns the one line that matters. It also makes a quieter promise: the test passes for that exact Person, and nobody knows whether it would pass for any other. Instancio fills the whole object graph with random data through reflection, and lets you pin only the fields your test is about. Whenever a generated value breaks something, a seed lets you replay the exact same data. It is a growing library — version 6 added Kotlin support and element selectors, and 6.0.0 reached Maven Central on 2026-08-29 and 6.1.0 on 2026-09-26 — yet most tutorials still describe the 2023 API. In particular, the JUnit 5 era @InstancioSource annotation no longer exists in instancio-junit 6.x; this post shows what replaced it, with everything compiled and run on JUnit 6.
Versions tested. instancio-junit and instancio-core 6.1.0 (latest GA, uploaded to Maven Central on 2026-09-26; 6.0.0 followed four release candidates), JUnit Jupiter 6.1.3 (latest GA, uploaded 2026-08-07; instancio-junit 6.1.0 is itself built against 6.1.3), JDK 25 (Temurin 25.0.5+8), Maven with Surefire 3.5.6. Every figure and message below comes from a file in the companion repository, produced by a real run on a shared 2-vCPU machine.

Instancio builds the object graph for you, and one seed controls every random value

If you have used a builder or an “object mother” class, you know the cost: someone has to write it, and someone has to keep it in step with the class. Instancio skips that step. You hand it a type, it walks the fields (or record components, or constructor parameters) with reflection, and fills every leaf with a random value. A Person holds an Address, which holds strings; a Person also holds a List<Order>, which holds Orders, which hold a PaymentMethod. Instancio keeps walking until it reaches values it knows how to produce. The second idea is the one that makes random test data safe to use: all the randomness comes from one Random that is created from one seed. Know the seed and you can regenerate the same objects. Everything else in this post — selectors, settings, JUnit integration, failure reports — is a variation on those two ideas.
Person.classa type, nothing elseobject graph walked by reflectionUUID idString name, emailint ageAddress home, billingList<Order> ordersOrder -> PaymentMethod (sealed)one Randomcreated from one seedseed 42 -> same valuesno seed -> a new seedeach runEvery leaf value is drawn from the same Random, so the seed alone decides what the whole graph contains.Selectors (next sections) override individual leaves; they do not change how the rest is drawn.
The diagram shows the whole library in one picture. The type goes in on the left. The graph in the middle is built from the type alone — you never described a single field. The green box on the right is why failures can be replayed: the same seed produces the same draws, in the same order.

The smallest thing that works is one line, and the defaults are random rather than realistic

Add one test dependency. instancio-junit pulls in instancio-core for you; with a plain JUnit 6 project that is all the setup there is (pom.xml).
<!-- instancio-junit brings instancio-core transitively -->
<dependency>
  <groupId>org.instancio</groupId>
  <artifactId>instancio-junit</artifactId>
  <version>${instancio.version}</version>
  <scope>test</scope>
</dependency>
The domain used throughout the post is a handful of Java records (Person.java, and its neighbours in the same package):
import java.util.List;
import java.util.UUID;

public record Person(UUID id, String name, String email, int age,
                     Address homeAddress, Address billingAddress, List<Order> orders) {
}
One line creates a fully populated Person (from BasicsTest.java):
Person p = Instancio.create(Person.class);
That is the entire API for the basic case. What it produces is the part worth looking at, because it explains most of the questions people ask next. The test below creates 200 people with a fixed seed and measures them (02-defaults-over-200-people.txt):
// 200 people from Instancio.of(Person.class).withSeed(1L).stream()
age range            : 2 .. 9919   (0 negative)
name length range    : 3 .. 10
emails containing '@': 0 of 200
orders per person    : 2 .. 6
sample name          : EOAPEZ
sample email         : DZSPMQC
Random is not realistic. By default Instancio gives you valid-shaped data, not plausible data: ages from 2 to 9919, names that are 3 to 10 random capital letters, and not one of 200 e-mail fields contains an @. That is the right default for most tests — it finds code that quietly assumes “names are short” or “age is under 120” — but it means any code that parses an e-mail or validates an age range needs you to say so. The next section is how.
There are a few other entry points you will use constantly: ofList(..).size(3), createSet, ofMap, stream() for an endless supply, and createBlank when you want an object with nothing filled in so that you can set only a few fields (03-entry-points.txt):
Instancio.create(String.class)                -> 4 chars
Instancio.ofList(Person.class).size(3)        -> list of 3
Instancio.createSet(Integer.class)            -> set of 2
Instancio.ofMap(String, Integer).size(2)      -> map of 2
Instancio.createBlank(Person.class)           -> id=null name=null age=0 orders=[]
collection size pinned to 0 via Settings      -> orders=[]
distinct names among the 3: 3
Going deeper: what Instancio can and cannot instantiate

Instancio uses a constructor, a setter, or a field depending on the class, and the order is configurable (Keys.ASSIGNMENT_TYPE, Keys.SETTER_STYLE). Records are built through their constructor. Interfaces and abstract classes need a subtype (section 3). The default maximum depth is 8, and cyclic graphs are cut off rather than recursed forever. Instancio.fill(existingObject) and ofObject populate an instance you already built. Hand-written value types with unusual constructors are the common source of “why is this null” questions; see the “Limitations” part of the user guide.

Pin the fields your test is about, and let everything else stay random

The rule that keeps Instancio tests readable: the test sets what it asserts on, and nothing else. Instancio has five verbs for this, and they differ in who produces the value:
VerbUse it whenExample value source
set(selector, value)you know the exact value"Asha Kulkarni"
generate(selector, gen -> ...)you want a random value with constraintsgen.ints().range(18, 65)
supply(selector, () -> ...)your own code produces the valuea counter, a factory, a fixture
ignore(selector)the field should stay at its Java defaultnull, 0, false
withNullable(selector)sometimes null is a valid inputrandom: null or a value
A selector says which field or type the verb applies to. field(Person::name) targets one component by method reference (so a rename breaks compilation instead of silently missing); all(Address.class) targets every Address in the graph. The first test sets a name, a valid e-mail, an age range, a six-digit postal code and a country (CustomiseTest.java):
Person p = Instancio.of(Person.class)
        .set(field(Person::name), "Asha Kulkarni")
        .generate(field(Person::email), gen -> gen.net().email())
        .generate(field(Person::age), gen -> gen.ints().range(18, 65))
        .generate(field(Address::postalCode), gen -> gen.string().digits().length(6))
        .set(field(Address::country), "IN")
        .create();
name                      = Asha Kulkarni
email matches e-mail regex = true
age in [18, 65]            = true
homeAddress.postalCode     matches \d{6} = true
homeAddress.country        = IN
billingAddress.country     = IN   // one selector, two matching fields
Look at the last line. field(Address::country) was written once but matched two fields, homeAddress.country and billingAddress.country, because both are components of Address. A selector matches by declaring class and name, not by position in the graph. If you want only one of them, scope it: field(Address::country).within(field(Person::homeAddress).toScope()) (shown in the user guide under selector scopes). supply, ignore and withNullable in one test (05-supply-ignore-nullable.txt):
int[] counter = {0};
Person p = Instancio.of(Person.class)
        .supply(field(Person::name), () -> "customer-" + (++counter[0]))
        .ignore(field(Person::billingAddress))
        .withNullable(field(Person::homeAddress))
        .create();
supply(name, () -> "customer-" + n)  -> customer-1
ignore(billingAddress)               -> null
ignore(age)  (an int)                -> 0
withNullable(homeAddress), 200 people, seed 7 -> 40 null, 160 non-null
ignore does not remove the field; it leaves the Java default, which is why the int ends up as 0. withNullable is the one to reach for when you want a test to cover the “address is missing” path: across 200 people it produced a mix of null and non-null, so a loop or a stream of people exercises both branches without you writing two tests. Selectors can target whole types too. This sets every Address to one fixed value and forces every order to be paid at a fixed total (06-selectors.txt):
// all(Address.class) targets every Address in the graph, including the ones inside Person.
Person p = Instancio.of(Person.class)
        .set(all(Address.class), new Address("1 MG Road", "Pune", "411001", "IN"))
        .set(field(Order::status), OrderStatus.PAID)
        .set(field(Order::total), new BigDecimal("499.00"))
        .create();
set(all(Address.class), pune)       -> home=Pune, billing=Pune
set(field(Order::status), PAID)     -> statuses of 5 orders: [PAID]
set(field(Order::total), 499.00)    -> totals: [499.00]

Interfaces and sealed types need a decision about which subtype

Order.payment is a PaymentMethod, which is a sealed interface with three records. If you do nothing, Instancio picks among the permitted subtypes (section 6 measures how evenly). If your test needs one, say which with subtype, or use set with an instance, and then keep customising the chosen record’s own components (07-subtype.txt):
Order o = Instancio.of(Order.class)
        .subtype(field(Order::payment), PaymentMethod.Card.class)
        .create();
// A card with a realistic expiry: drill into the record's own components.
Order card = Instancio.of(Order.class)
        .subtype(field(Order::payment), PaymentMethod.Card.class)
        .generate(field(PaymentMethod.Card::number), gen -> gen.string().digits().length(16))
        .generate(field(PaymentMethod.Card::expiry), gen -> gen.temporal().yearMonth().future())
        .create();
subtype(payment, Card.class)    -> Card
set(payment, new Cash())        -> Cash
card number length              -> 16
card expiry is in the future    -> true
A selector that matches nothing is an error, on purpose. Instancio is strict: if a selector you wrote does not match anything in the object being created, it throws rather than quietly ignoring it. That catches typos and tests left behind after a refactor. In the capture below, the selector field(Order::status) is applied while creating an Address, which contains no Order. The first block is a field name that does not exist, and it is worth reading to the end: the exception is an InstancioApiException, and its reason appears several lines down. .lenient() turns the check off for one call (08-unused-selector.txt).
field(Person.class, "doesNotExist"), first 8 lines:
  org.instancio.exception.InstancioApiException:
  
  Error creating an object
   -> at lab.instancio.CustomiseTest.lambda$unusedSelectorsAreAnErrorByDefault$0(CustomiseTest.java:138)
  
  Reason: invalid field 'doesNotExist' for class lab.instancio.domain.Person
set(field(Order::status), ..) on an Address graph, first 8 lines:
  org.instancio.exception.UnusedSelectorException:
  Found unused selectors referenced in the following methods:
  
   -> Unused selector in: set()
   1: field(Order::status)
      at lab.instancio.CustomiseTest.lambda$unusedSelectorsAreAnErrorByDefault$1(CustomiseTest.java:139)
  
  This error aims to highlight potential problems and help maintain clean test code.
with .lenient() the same call succeeds: true
Going deeper: more verbs, scopes and predicates

The same builder also has assign(Assign.given(...).set(...)) for conditional rules (“when status is CANCELLED, set total to zero”), filter to reject values until a predicate holds, onComplete for a callback on each created value, setBlank to create an empty instance of a type, withUnique to avoid duplicates in a collection, and Select.elementOf(..) to target particular elements of a list (experimental since 6.0.0). Predicate selectors (Select.fields().named("id"), Select.types().of(String.class)) match by rule rather than by name. Selector precedence decides what wins when several match the same target: regular selectors beat predicate selectors, field selectors beat type selectors, and between two selectors of the same kind the last one wins.

Settings change the shape of the data for a whole test class instead of one field at a time

Selectors are for one field. When you want every collection to have one or two elements, or every string to be 8 to 12 lower-case letters, set it once with Settings. The keys live in org.instancio.settings.Keys. This test applies seven settings and then measures what came out of 100 people (SettingsAndSeedTest.java):
Settings settings = Settings.create()
        .set(Keys.COLLECTION_MIN_SIZE, 1)
        .set(Keys.COLLECTION_MAX_SIZE, 2)
        .set(Keys.STRING_MIN_LENGTH, 8)
        .set(Keys.STRING_MAX_LENGTH, 12)
        .set(Keys.STRING_TYPE, StringType.ALPHABETIC)
        .set(Keys.STRING_CASE, StringCase.LOWER)
        .set(Keys.INTEGER_MIN, 18)
        .set(Keys.INTEGER_MAX, 80);

List<Person> people = Instancio.of(Person.class).withSettings(settings).withSeed(3L).stream().limit(100).toList();
// 100 people, Settings: collection 1..2, string length 8..12, lower-case alphabetic, int 18..80
orders per person : 1 .. 2
name length       : 8 .. 12
age               : 18 .. 80
all names lower-case: true
sample name       : zopbsphrupid
Settings are layered, and the layering explains most “why did my setting not apply” questions. From weakest to strongest: library defaults, an instancio.properties file at the classpath root, INSTANCIO_* environment variables, instancio.* system properties, a @WithSettings field in a JUnit test class (section 7), and finally withSettings(..) on the builder. A project-wide instancio.properties with collection.min.size=1 and string.max.length=20 is the cheapest way to make every test in a module use saner defaults.
Going deeper: settings keys worth knowing

Besides sizes, lengths and number ranges: Keys.MAX_DEPTH limits how deep the graph goes; Keys.MODE (STRICT or LENIENT) is the setting behind the unused-selector check from section 3; Keys.ASSIGNMENT_TYPE and Keys.SETTER_STYLE choose between fields and setters for non-record classes; Keys.SEED pins the seed (section 5). Each type has its own nullable key (Keys.STRING_NULLABLE, Keys.COLLECTION_NULLABLE) if you want a whole type to be sometimes null.

A seed turns random data into data you can replay

This is the feature that makes random test data defensible in a code review. Without a seed, each Instancio.create uses a fresh random seed, so the data differs on every run. With withSeed(42), the draws are fixed. Because the objects here are records, equals() compares every component recursively, so a single assertEquals proves the entire graph — UUIDs, strings, nested lists, the sealed payment method — is identical (SettingsAndSeedTest.java):
Person a = Instancio.of(Person.class).withSeed(42L).create();
Person b = Instancio.of(Person.class).withSeed(42L).create();
Person c = Instancio.of(Person.class).withSeed(43L).create();

assertEquals(a, b, "records compare component by component, so equals() proves identical content");
assertNotEquals(a, c);
create with seed 42, first call : LZORARZVM
create with seed 42, second call: LZORARZVM
a.equals(b)  (whole object graph): true
a.hashCode() == b.hashCode()     : true
create with seed 43              : UETEFLRO
a.equals(c)                      : false
seed 42 set through Keys.SEED    : equals a = true
withSeed(42)withSeed(42)withSeed(43)Person ALZORARZVM…Person BLZORARZVM…Person CUETEFLRO…A.equals(B) = trueA.equals(C) = falseThe seed is the only input. Same seed, same library version, same code: same graph.Names are the first characters of each name from output/10-seed-reproducibility.txt.
The picture restates the transcript: the two green boxes are the same input and give equal results; the amber one changes one digit of the seed and the whole graph changes. Streams are covered too — five people from seed 5 come out in the same order on a second run (11-seed-streams.txt):
stream, seed 5, run 1: [QRUQXUOH, EPTMBHO, NSZCUSO, YROHHKRIGM, YXNEDMV]
stream, seed 5, run 2: [QRUQXUOH, EPTMBHO, NSZCUSO, YROHHKRIGM, YXNEDMV]
identical            : true
5 creates without a seed -> 5 distinct names
seed 9 twice -> same orders list: true
Replayable is not the same as stable forever. Instancio guarantees identical data for a given seed and a given version of the library, and says so in its user guide. Upgrading Instancio can change what seed 42 produces, and generators that depend on the clock (the past() and future() temporal generators) are marked non-deterministic. So never hard-code a generated value in an assertion; assert a property of the data instead. The 07 transcript’s expiry check isAfter(YearMonth.now()) does exactly that.
Going deeper: where a seed can come from

Three places, strongest last: Keys.SEED in a Settings object (the transcript above shows seed 42 set that way equals withSeed(42)), withSeed(long) on a single builder, and the JUnit @Seed annotation on a test method (section 7). Inside a JUnit test run by InstancioExtension every test method gets its own random seed, and one Random instance is shared by all Instancio calls in that method, so two consecutive Instancio.create(Person.class) calls inside one test still give two different objects — they are the first and second draw from the same sequence.

Records, sealed types and generics work, with two corners that behave differently from what you expect

Modern Java models data as records, hierarchies as sealed interfaces, and wrappers as generic types. Instancio supports all three; the user guide says record support arrived in version 1.5.0 (Java 16+) and sealed classes in the same release for Java 17+. Three behaviours are worth seeing run (GenericsAndSealedTest.java).

Sealed hierarchies: every permitted subtype shows up

The sealed interface from the domain package, with its three records (PaymentMethod.java):
public sealed interface PaymentMethod {

    record Card(String number, YearMonth expiry) implements PaymentMethod {
    }

    record BankTransfer(String iban) implements PaymentMethod {
    }

    record Cash() implements PaymentMethod {
    }
}
Creating 300 of them from seed 11 (12-sealed.txt):
// 300 x Instancio.of(PaymentMethod.class).withSeed(11L)  (sealed: Card, BankTransfer, Cash)
subtypes seen: {BankTransfer=90, Card=105, Cash=105}
Roughly a third each. With no subtype() call, Instancio chose among the permitted records at random, which means a test written against Order exercises card, bank transfer and cash payments across runs without you listing them.

Generic types need a TypeToken, because the type argument is erased

Page<Person> and Page<String> are the same class at runtime, so Page.class cannot say what the items are. new TypeToken<Page<Person>>() {} keeps the type argument alive in a generic superclass, which reflection can read. Two details from writing this test: method references do not compile for a generic record (field(Page::items) fails type inference, so the test uses field(Page.class, "items")), and the raw-class call is rejected with a message that tells you the fix (13-type-token.txt):
Page<Person> page = Instancio.of(new TypeToken<Page<Person>>() {})
        .generate(field(Page.class, "pageNumber"), gen -> gen.ints().range(1, 10))
        .size(field(Page.class, "items"), 4)
        .create();
Instancio.of(new TypeToken<Page<Person>>() {}).size(items, 4)
  items.size()                    = 4
  items.get(0) is a Person        = true
  pageNumber in [1,10]            = true
Instancio.create(new TypeToken<Map<String, List<Order>>>() {})
  entries                         = 6, first value element is an Order = true
Instancio.create(Page.class)  (raw class, T unknown), first 8 lines of the message:
  Error creating an object
   -> at lab.instancio.GenericsAndSealedTest.rawFailureMessage(GenericsAndSealedTest.java:99)
  
  Reason: incorrect type parameters specified
  
   -> Class lab.instancio.domain.Page requires 1 type parameter(s): [T]
   -> The number of parameters provided was 0

A record whose constructor throws becomes null, not an exception

This is the corner to know about. Records can validate in a compact constructor, and Instancio has to call that constructor, so what happens when the validation rejects a random value? In 6.1.0, as run here: no exception, and the created element is null. Twenty instances of a record that accepts 0 to 100, generated with the default integer range of 1 to 10000, gave twenty nulls. The tests that follow would then fail with a NullPointerException far from the cause. Constrain the generator to the record’s own valid range and the nulls disappear (14-record-invariant.txt):
record Percent(int value) {
    Percent {
        if (value < 0 || value > 100) {
            throw new IllegalArgumentException("value out of range: " + value);
        }
    }
}
record Percent(int value) { compact ctor rejects value < 0 or > 100 }
Instancio.of(Percent.class).withSeed(1L), 20 instances, default ints 1..10000:
  no exception thrown; null elements: 20 of 20
with generate(all(int.class), ints().range(0, 100)):
  null elements: 0 of 20, first values: [Percent[value=93], Percent[value=22], Percent[value=75]]
Check for nulls when a type validates itself. If a record, value object or entity throws from its constructor and you create it with defaults, assert the result is non-null, or give Instancio a generator that respects the invariant (gen.ints().range(0, 100) above). I did not find a documented setting that turns this into a loud failure, and I only observed it for a record whose constructor throws; treat it as behaviour of 6.1.0 on JDK 25 rather than a documented guarantee.
Going deeper: type parameters, Kotlin, Java Time and Bean Validation

The same TypeToken works for any nesting (Map<String, List<Order>> in the transcript above). Instancio.of(Map.class).withTypeParameters(String.class, Order.class) is the alternative for raw classes. Instancio 6 ships an instancio-kotlin module with Kotlin property references. The library also reads Jakarta Bean Validation and Hibernate Validator annotations (@Size, @Email, @Min) when they are on the classpath, which is the other route to “realistic” data and is worth trying before writing many generate calls.

With JUnit 6 the extension injects data, settings and seeds, and parameterized tests use @Given instead of @InstancioSource

So far everything was plain method calls. instancio-junit adds InstancioExtension, which does four jobs for a test class: it creates a seed for each test method, injects Settings into a field marked @WithSettings, fills fields and parameters marked @Given, and reports the seed when a test fails (section 8). Add it with @ExtendWith(InstancioExtension.class) (JUnitExtensionTest.java):
@ExtendWith(InstancioExtension.class)
class JUnitExtensionTest {

    // Applied to every Instancio call made by this test class (non-static is fine since 6.0.0).
    @WithSettings
    private final Settings settings = Settings.create()
            .set(Keys.COLLECTION_MIN_SIZE, 1)
            .set(Keys.COLLECTION_MAX_SIZE, 3)
            .set(Keys.INTEGER_MIN, 18)
            .set(Keys.INTEGER_MAX, 90);

    @Given
    private Person injectedField;
Both settings and injectedField are instance fields. Before 6.0.0 a class containing a @ParameterizedTest needed the @WithSettings field to be static; the user guide notes that restriction is gone.
1. Seed pickedrandom, or @Seed(n)2. @WithSettingsinjected into the field3. @Givenfields and parameters filled4. Test bodyInstancio.create() shares the Random5. On failureseed reportedThe same Random is used from step 1 to step 5, which is why a reported seed replays steps 3 and 4 exactly.Parameterized tests: each invocation is its own run through steps 1 to 5, with its own seed.
The diagram is the lifecycle of one test method. Step 1 happens before anything is generated, which is what lets step 5 report a seed that reproduces steps 3 and 4. Each repetition of a @RepeatedTest or each parameterized invocation is a separate run through the same five steps.

Parameterized tests: @Given supplements the arguments, and @MethodSource can stream Instancio data

If you are porting a JUnit 5 suite, the first surprise is that @InstancioSource is gone. I compared the jars on Maven Central: instancio-junit 5.5.1 contains org/instancio/junit/InstancioSource.class; 6.0.0 and 6.1.0 do not, and the 6.1.0 user guide never mentions it. In its place, @Given (which already existed in 5.x) works on @Test parameters, @RepeatedTest parameters and as additional parameters of a @ParameterizedTest whose main argument comes from another source such as @ValueSource. When the whole argument should be an Instancio object, a @MethodSource that returns Instancio.of(..).stream() does the job (15-junit-extension.txt):
@ParameterizedTest
@ValueSource(strings = {"IN", "US", "DE"})
void valueSourcePlusGivenParameter(String country, @Given Person p) {
static Stream<Person> people() {
    return Instancio.of(Person.class).withSeed(100L).stream().limit(3);
}

@ParameterizedTest
@MethodSource("people")
void methodSourceBackedByInstancioStream(Person p) {
// instancio-junit 6.1.0 on JUnit Jupiter 6.1.3, lines collected inside the tests
@Given field and parameter: orders 3 and 2, ages in [18, 90]: true
@MethodSource Instancio.stream(): BWC with 3 orders
@MethodSource Instancio.stream(): PLDXIMRQFI with 4 orders
@MethodSource Instancio.stream(): ZQTUH with 4 orders
@ParameterizedTest [DE] gets its own Person, age 89
@ParameterizedTest [IN] gets its own Person, age 20
@ParameterizedTest [US] gets its own Person, age 75
@Seed(2026) on two different test methods + @Seed(2027) on a third: 3 Persons, 2 distinct
The last line of that transcript is an assertion made after all tests ran: two different test methods annotated @Seed(2026) received the same Person, while @Seed(2027) received a different one — three people, two distinct. That is the whole contract of @Seed. Note also that the @MethodSource people have 3 and 4 orders even though @WithSettings caps collections at 3: a static source method runs outside the extension’s per-test settings, so pass withSettings(..) there explicitly.
@InstancioSource is gone in 6.x. If a JUnit 5 tutorial tells you to write @ParameterizedTest @InstancioSource void test(Person p), it will not compile against instancio-junit 6.x. Translate it to @RepeatedTest(n) with @Given parameters when you simply want many random samples, or to @MethodSource with Instancio.stream(..).limit(n) when you need a real @ParameterizedTest. For combinations rather than random samples, look at Instancio.ofCartesianProduct(..), which is in the 6.1.0 jar but not exercised here.
Does it work with JUnit 6? Yes, as run for this post. The instancio-junit 6.1.0 POM declares junit-jupiter-api and junit-platform-commons 6.1.3 as provided dependencies, and the whole suite above, including the extension, @Given, @Seed and @WithSettings, ran on JUnit Jupiter 6.1.3 with no adaptation. InstancioExtension also implements getTestInstantiationExtensionContextScope, which comes from JUnit’s TestInstantiationAwareExtension interface, so it is written against the current extension API.
Going deeper: custom @Given providers and nested settings

A GivenProvider turns @Given into your own annotation, for example a @NumericString(length = 5) parameter; the provider receives the same Random, so its values replay with the seed. @Given can also be repeated, in which case a provider is picked at random from the candidates. Settings declared in superclasses and enclosing classes are merged, and the declaration closest to the test wins, which makes a shared base class with baseline settings practical. Constructor parameters are not supported because JUnit resolves them before the extension has initialised the seed.

When a generated value breaks a test, the seed is in the failure output — if a logger is present

Random data has one real cost: a test that fails on Tuesday and passes on Wednesday. The seed removes that cost, but only if you can find it. To see what Instancio actually prints I wrote a deliberately failing test against a deliberately buggy discount rule (the off-by-one is in DiscountService.java):
public static int discountPercent(int age) {
    if (age >= 65) {
        return 20;
    }
    if (age > 18) { // off-by-one on purpose: age 18 gets nothing
        return 10;
    }
    return 0;
}
The test draws 40 adults aged 18 to 30. With 13 possible ages, some repetition is very likely to land on 18 (FailingSeedDemoTest.java). It only runs when you pass -Dlab.demo=fail, so it never breaks the normal build:
@RepeatedTest(40)
void everyAdultGetsADiscount(@Given Person adult) {
    assertTrue(DiscountService.discountPercent(adult.age()) > 0,
            "age " + adult.age() + " should get a discount");
Here is the first surprise. Run it with the dependencies from section 2 and read the console (16-failing-test-without-slf4j.txt):
$ mvn test -Dtest=FailingSeedDemoTest -Dlab.demo=fail
[INFO] Running lab.instancio.FailingSeedDemoTest
SLF4J(W): No SLF4J providers were found.
SLF4J(W): Defaulting to no-operation (NOP) logger implementation
SLF4J(W): See https://www.slf4j.org/codes.html#noProviders for further details.
[ERROR] Tests run: 40, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 6.272 s <<< FAILURE! -- in lab.instancio.FailingSeedDemoTest
[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[32] -- Time elapsed: 0.444 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[INFO] Results:
[ERROR] Failures: 
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR] Tests run: 40, Failures: 1, Errors: 0, Skipped: 0
lines containing '@Seed' in the whole Maven output: 0
The test failed, JUnit told us which repetition and why, and the last line counts how many times the string @Seed appears in the whole Maven output: zero. The two SLF4J(W) lines are the reason. instancio-core logs through slf4j-api, which is only a facade; with no logging implementation on the classpath it switches to a no-operation logger and discards everything. Instancio does publish the seed as a JUnit report entry, which IDEs show, but in this run it did not appear anywhere in the Maven console output. Add any SLF4J provider and the summary appears. The repository has a Maven profile, seed-log, that adds slf4j-simple as a test dependency (pom.xml); the real message follows (17-failing-test-seed-summary.txt):
$ mvn test -Pseed-log -Dtest=FailingSeedDemoTest -Dlab.demo=fail
[INFO] Running lab.instancio.FailingSeedDemoTest
[ERROR] Tests run: 40, Failures: 5, Errors: 0, Skipped: 0, Time elapsed: 4.204 s <<< FAILURE! -- in lab.instancio.FailingSeedDemoTest
[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[4] -- Time elapsed: 0.128 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[12] -- Time elapsed: 0.030 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[24] -- Time elapsed: 0.011 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[34] -- Time elapsed: 0.008 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[37] -- Time elapsed: 0.007 s <<< FAILURE!
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>

[main] WARN org.instancio.log.test.failure.seed - 5 tests failed. A failure can be reproduced by annotating the test method with @Seed:

  @Seed(1592129627343010309L) FailingSeedDemoTest.everyAdultGetsADiscount repetition 4 of 40 (seed source: random seed)
  @Seed(4014505636531349958L) FailingSeedDemoTest.everyAdultGetsADiscount repetition 12 of 40 (seed source: random seed)
  @Seed(3480942169170837161L) FailingSeedDemoTest.everyAdultGetsADiscount repetition 24 of 40 (seed source: random seed)
  @Seed(2463961442792581393L) FailingSeedDemoTest.everyAdultGetsADiscount repetition 34 of 40 (seed source: random seed)
  @Seed(4455880775937018043L) FailingSeedDemoTest.everyAdultGetsADiscount repetition 37 of 40 (seed source: random seed)

[INFO] Results:
[ERROR] Failures: 
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR]   FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: <true> but was: <false>
[ERROR] Tests run: 40, Failures: 5, Errors: 0, Skipped: 0
Each failing repetition has its own seed — that is the line to copy. Because this run uses random seeds, the numbers differ every time you run it; the committed transcript is simply the run that produced the file. Now paste one of those seeds on the test method. The reproduction below uses a seed I copied from an earlier run of the same test, and it is a plain @Test, not a repetition, which shows the seed is independent of how the test is launched (ReproduceFailureDemoTest.java):
@Seed(2016013118612999037L)
@Test
void everyAdultGetsADiscount(@Given Person adult) {
    assertTrue(DiscountService.discountPercent(adult.age()) > 0,
            "age " + adult.age() + " should get a discount");
Run twice, it fails twice on the same data, and Instancio reports the pinned seed with a different seed source (18-reproduced-with-seed.txt):
$ mvn test -Pseed-log -Dtest=ReproduceFailureDemoTest -Dlab.demo=reproduce   (run 1)
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.399 s <<< FAILURE! -- in lab.instancio.ReproduceFailureDemoTest
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>
[main] WARN org.instancio.log.test.failure.seed - 1 test failed. A failure can be reproduced by annotating the test method with @Seed:
  @Seed(2016013118612999037L) ReproduceFailureDemoTest.everyAdultGetsADiscount (seed source: @Seed)
$ same command again   (run 2)
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.159 s <<< FAILURE! -- in lab.instancio.ReproduceFailureDemoTest
org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: <true> but was: <false>
[main] WARN org.instancio.log.test.failure.seed - 1 test failed. A failure can be reproduced by annotating the test method with @Seed:
  @Seed(2016013118612999037L) ReproduceFailureDemoTest.everyAdultGetsADiscount (seed source: @Seed)
Test fails in CIon random dataSeed summaryneeds an SLF4J providerCopy @Seed(n)onto the test methodFails the sameway, locallyThe blue box is the step people miss: with no logging implementation the summary is silently dropped.Fix the bug, then delete the @Seed so the test goes back to exploring new data.
The flow in the diagram is the whole debugging loop. The last line matters as much as the others: a pinned @Seed is a debugging tool, not a fixture. Leave it in and the test never explores new data again.
What gets reported and what does not. The user guide says only the seed Instancio generated internally is reported, because a seed you set yourself with withSeed() or Settings is already known. A seed supplied through @Seed is reported too, labelled seed source: @Seed in the transcript above, which is useful when a colleague pastes a seed into a branch and you want to confirm it was used.
Going deeper: logging categories and CI

The summary is logged under the category org.instancio.log.test.failure.seed (visible in the transcript as the logger name), so you can route it to your CI log at WARN level without turning on other Instancio logging. In a Spring Boot build a logging implementation is usually already present and you get the summary with no extra work; the plain-Maven case in this post is where it goes missing. Data that depends on the clock, such as gen.temporal().localDate().past(), is not covered by the seed guarantee.

Should you generate your test data at all?

Honestly: use it where the data is not the point, and not where it is. Instancio is a good fit for tests where the object is scaffolding — mappers, serialisation round trips, validation of one field, repository tests that need fifty rows with unique keys. It removes setup noise and finds the “I assumed names are short” bugs for free. It is a poor fit when the exact data is the specification (a tax calculation with known inputs and known outputs belongs in a table of hand-written cases), and it does not replace property-based testing: I did not find any shrinking of a failing input to a minimal case in the API, so a failure hands you a large random object and a seed, not the smallest counter-example. Three habits keep it safe: pin only what the assertion depends on, never assert on a literal generated value, and keep the seed output visible in CI (section 8). If a record validates itself, check for nulls (section 6).

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.