From 11782a7ec32965709f0390d1ae381f020b5b5521 Mon Sep 17 00:00:00 2001 From: asmhatre Date: Thu, 29 Oct 2026 05:51:26 +0000 Subject: [PATCH] instancio: Instancio 6.1 lab (selectors, settings, seeds, records/sealed/generics, JUnit 6 extension, failing-seed capture) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ --- README.md | 1 + instancio/.gitignore | 1 + instancio/README.md | 27 +++ instancio/output/01-create-person.txt | 7 + .../output/02-defaults-over-200-people.txt | 7 + instancio/output/03-entry-points.txt | 7 + instancio/output/04-set-and-generate.txt | 6 + .../output/05-supply-ignore-nullable.txt | 4 + instancio/output/06-selectors.txt | 3 + instancio/output/07-subtype.txt | 4 + instancio/output/08-unused-selector.txt | 17 ++ instancio/output/09-settings.txt | 6 + instancio/output/10-seed-reproducibility.txt | 7 + instancio/output/11-seed-streams.txt | 5 + instancio/output/12-sealed.txt | 2 + instancio/output/13-type-token.txt | 14 ++ instancio/output/14-record-invariant.txt | 5 + instancio/output/15-junit-extension.txt | 9 + .../output/16-failing-test-without-slf4j.txt | 14 ++ .../output/17-failing-test-seed-summary.txt | 34 ++++ instancio/output/18-reproduced-with-seed.txt | 10 ++ instancio/pom.xml | 58 ++++++ instancio/scripts/capture-failure.sh | 34 ++++ instancio/scripts/run-all.sh | 8 + .../java/lab/instancio/domain/Address.java | 4 + .../lab/instancio/domain/DiscountService.java | 19 ++ .../main/java/lab/instancio/domain/Order.java | 7 + .../lab/instancio/domain/OrderStatus.java | 3 + .../main/java/lab/instancio/domain/Page.java | 7 + .../lab/instancio/domain/PaymentMethod.java | 16 ++ .../java/lab/instancio/domain/Person.java | 8 + .../test/java/lab/instancio/BasicsTest.java | 93 ++++++++++ .../java/lab/instancio/CustomiseTest.java | 165 ++++++++++++++++++ .../lab/instancio/FailingSeedDemoTest.java | 34 ++++ .../lab/instancio/GenericsAndSealedTest.java | 105 +++++++++++ .../lab/instancio/JUnitExtensionTest.java | 106 +++++++++++ .../instancio/ReproduceFailureDemoTest.java | 36 ++++ .../lab/instancio/SettingsAndSeedTest.java | 103 +++++++++++ .../test/java/lab/instancio/Transcript.java | 57 ++++++ 39 files changed, 1053 insertions(+) create mode 100644 instancio/.gitignore create mode 100644 instancio/README.md create mode 100644 instancio/output/01-create-person.txt create mode 100644 instancio/output/02-defaults-over-200-people.txt create mode 100644 instancio/output/03-entry-points.txt create mode 100644 instancio/output/04-set-and-generate.txt create mode 100644 instancio/output/05-supply-ignore-nullable.txt create mode 100644 instancio/output/06-selectors.txt create mode 100644 instancio/output/07-subtype.txt create mode 100644 instancio/output/08-unused-selector.txt create mode 100644 instancio/output/09-settings.txt create mode 100644 instancio/output/10-seed-reproducibility.txt create mode 100644 instancio/output/11-seed-streams.txt create mode 100644 instancio/output/12-sealed.txt create mode 100644 instancio/output/13-type-token.txt create mode 100644 instancio/output/14-record-invariant.txt create mode 100644 instancio/output/15-junit-extension.txt create mode 100644 instancio/output/16-failing-test-without-slf4j.txt create mode 100644 instancio/output/17-failing-test-seed-summary.txt create mode 100644 instancio/output/18-reproduced-with-seed.txt create mode 100644 instancio/pom.xml create mode 100755 instancio/scripts/capture-failure.sh create mode 100755 instancio/scripts/run-all.sh create mode 100644 instancio/src/main/java/lab/instancio/domain/Address.java create mode 100644 instancio/src/main/java/lab/instancio/domain/DiscountService.java create mode 100644 instancio/src/main/java/lab/instancio/domain/Order.java create mode 100644 instancio/src/main/java/lab/instancio/domain/OrderStatus.java create mode 100644 instancio/src/main/java/lab/instancio/domain/Page.java create mode 100644 instancio/src/main/java/lab/instancio/domain/PaymentMethod.java create mode 100644 instancio/src/main/java/lab/instancio/domain/Person.java create mode 100644 instancio/src/test/java/lab/instancio/BasicsTest.java create mode 100644 instancio/src/test/java/lab/instancio/CustomiseTest.java create mode 100644 instancio/src/test/java/lab/instancio/FailingSeedDemoTest.java create mode 100644 instancio/src/test/java/lab/instancio/GenericsAndSealedTest.java create mode 100644 instancio/src/test/java/lab/instancio/JUnitExtensionTest.java create mode 100644 instancio/src/test/java/lab/instancio/ReproduceFailureDemoTest.java create mode 100644 instancio/src/test/java/lab/instancio/SettingsAndSeedTest.java create mode 100644 instancio/src/test/java/lab/instancio/Transcript.java diff --git a/README.md b/README.md index caa50b2..166f659 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Runnable companion code for the testing posts on [ankurm.com](https://ankurm.com | [`mockito/`](mockito) | Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs | Mockito 5.24.0, JUnit 6.1.3, JDK 25 | | [`junit-6-migration/`](junit-6-migration) | JUnit 5 to 6 Migration: What Was Removed, What Changed and How to Upgrade | JUnit 5.14.4 and 6.1.3, JDK 25 | | [`assertj/`](assertj) | AssertJ Complete Guide: Soft Assertions, Recursive Comparison and Custom Assertions | AssertJ 3.27.7, JUnit 6.1.3, JDK 25 | +| [`instancio/`](instancio) | Instancio: Generate Realistic Test Data Automatically in Java | Instancio 6.1.0, JUnit 6.1.3, JDK 25 | Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true. diff --git a/instancio/.gitignore b/instancio/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/instancio/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/instancio/README.md b/instancio/README.md new file mode 100644 index 0000000..5f54493 --- /dev/null +++ b/instancio/README.md @@ -0,0 +1,27 @@ +# instancio + +Companion code for the ankurm.com post *Instancio: Generate Realistic Test Data Automatically in Java* (https://ankurm.com/instancio-java-generate-test-data/). + +| Component | Version | +|---|---| +| instancio-junit / instancio-core | 6.1.0 (GA; `6.0.0-RC1..RC4` exist on Maven Central but are release candidates) | +| JUnit Jupiter | 6.1.3 (the version `instancio-junit` 6.1.0 itself is built against) | +| slf4j-simple | 2.0.19, only in the optional `seed-log` profile | +| JDK | 25 (Temurin 25.0.5+8) | + +```bash +./scripts/run-all.sh # tests + failure captures; regenerates output/*.txt +``` + +Each test writes a transcript to `output/` and asserts the same facts. Not stable between runs: `01`, `03` (random sizes), `15` (random ages), and the seeds in `17` and `18`. + +| Test | Output | Topic | +|---|---|---| +| `BasicsTest` | 01-03 | `create`, what the defaults look like, other entry points | +| `CustomiseTest` | 04-08 | `set`, `generate`, `supply`, `ignore`, `withNullable`, `subtype`, selectors, unused-selector error | +| `SettingsAndSeedTest` | 09-11 | `Settings` keys, same seed gives equal objects | +| `GenericsAndSealedTest` | 12-14 | sealed hierarchy, `TypeToken`, record compact constructor | +| `JUnitExtensionTest` | 15 | `InstancioExtension`, `@WithSettings`, `@Given`, `@Seed`, parameterized tests | +| `FailingSeedDemoTest`, `ReproduceFailureDemoTest` | 16-18 | fail on purpose (only with `-Dlab.demo=...`), seed summary, reproduction | + +`@InstancioSource` no longer exists in instancio-junit 6.x; the module uses `@Given` and `@MethodSource` instead. diff --git a/instancio/output/01-create-person.txt b/instancio/output/01-create-person.txt new file mode 100644 index 0000000..4f38985 --- /dev/null +++ b/instancio/output/01-create-person.txt @@ -0,0 +1,7 @@ +// Instancio.create(Person.class) -- no seed pinned, so values differ on every run +id = (non-null: true) +name = <4 chars> +email = <10 chars> (a random string, NOT an e-mail address) +homeAddress non-null: true +billingAddress non-null: true +orders.size() = 3 (default collection size is 2..6) diff --git a/instancio/output/02-defaults-over-200-people.txt b/instancio/output/02-defaults-over-200-people.txt new file mode 100644 index 0000000..9fa27b5 --- /dev/null +++ b/instancio/output/02-defaults-over-200-people.txt @@ -0,0 +1,7 @@ +// 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 diff --git a/instancio/output/03-entry-points.txt b/instancio/output/03-entry-points.txt new file mode 100644 index 0000000..9b2f06e --- /dev/null +++ b/instancio/output/03-entry-points.txt @@ -0,0 +1,7 @@ +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 diff --git a/instancio/output/04-set-and-generate.txt b/instancio/output/04-set-and-generate.txt new file mode 100644 index 0000000..3358af0 --- /dev/null +++ b/instancio/output/04-set-and-generate.txt @@ -0,0 +1,6 @@ +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 diff --git a/instancio/output/05-supply-ignore-nullable.txt b/instancio/output/05-supply-ignore-nullable.txt new file mode 100644 index 0000000..c7ce96a --- /dev/null +++ b/instancio/output/05-supply-ignore-nullable.txt @@ -0,0 +1,4 @@ +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 diff --git a/instancio/output/06-selectors.txt b/instancio/output/06-selectors.txt new file mode 100644 index 0000000..ee9e1b6 --- /dev/null +++ b/instancio/output/06-selectors.txt @@ -0,0 +1,3 @@ +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] diff --git a/instancio/output/07-subtype.txt b/instancio/output/07-subtype.txt new file mode 100644 index 0000000..5987713 --- /dev/null +++ b/instancio/output/07-subtype.txt @@ -0,0 +1,4 @@ +subtype(payment, Card.class) -> Card +set(payment, new Cash()) -> Cash +card number length -> 16 +card expiry is in the future -> true diff --git a/instancio/output/08-unused-selector.txt b/instancio/output/08-unused-selector.txt new file mode 100644 index 0000000..8229d0a --- /dev/null +++ b/instancio/output/08-unused-selector.txt @@ -0,0 +1,17 @@ +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 diff --git a/instancio/output/09-settings.txt b/instancio/output/09-settings.txt new file mode 100644 index 0000000..0b79c46 --- /dev/null +++ b/instancio/output/09-settings.txt @@ -0,0 +1,6 @@ +// 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 diff --git a/instancio/output/10-seed-reproducibility.txt b/instancio/output/10-seed-reproducibility.txt new file mode 100644 index 0000000..acfad87 --- /dev/null +++ b/instancio/output/10-seed-reproducibility.txt @@ -0,0 +1,7 @@ +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 diff --git a/instancio/output/11-seed-streams.txt b/instancio/output/11-seed-streams.txt new file mode 100644 index 0000000..61712d2 --- /dev/null +++ b/instancio/output/11-seed-streams.txt @@ -0,0 +1,5 @@ +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 diff --git a/instancio/output/12-sealed.txt b/instancio/output/12-sealed.txt new file mode 100644 index 0000000..2e3ee31 --- /dev/null +++ b/instancio/output/12-sealed.txt @@ -0,0 +1,2 @@ +// 300 x Instancio.of(PaymentMethod.class).withSeed(11L) (sealed: Card, BankTransfer, Cash) +subtypes seen: {BankTransfer=90, Card=105, Cash=105} diff --git a/instancio/output/13-type-token.txt b/instancio/output/13-type-token.txt new file mode 100644 index 0000000..4676a61 --- /dev/null +++ b/instancio/output/13-type-token.txt @@ -0,0 +1,14 @@ +Instancio.of(new TypeToken>() {}).size(items, 4) + items.size() = 4 + items.get(0) is a Person = true + pageNumber in [1,10] = true +Instancio.create(new TypeToken>>() {}) + 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 diff --git a/instancio/output/14-record-invariant.txt b/instancio/output/14-record-invariant.txt new file mode 100644 index 0000000..72d2914 --- /dev/null +++ b/instancio/output/14-record-invariant.txt @@ -0,0 +1,5 @@ +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]] diff --git a/instancio/output/15-junit-extension.txt b/instancio/output/15-junit-extension.txt new file mode 100644 index 0000000..da5afe0 --- /dev/null +++ b/instancio/output/15-junit-extension.txt @@ -0,0 +1,9 @@ +// 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 diff --git a/instancio/output/16-failing-test-without-slf4j.txt b/instancio/output/16-failing-test-without-slf4j.txt new file mode 100644 index 0000000..35f6136 --- /dev/null +++ b/instancio/output/16-failing-test-without-slf4j.txt @@ -0,0 +1,14 @@ +$ 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: but was: + +[INFO] Results: +[ERROR] Failures: +[ERROR] FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: but was: +[ERROR] Tests run: 40, Failures: 1, Errors: 0, Skipped: 0 +lines containing '@Seed' in the whole Maven output: 0 diff --git a/instancio/output/17-failing-test-seed-summary.txt b/instancio/output/17-failing-test-seed-summary.txt new file mode 100644 index 0000000..369ad0c --- /dev/null +++ b/instancio/output/17-failing-test-seed-summary.txt @@ -0,0 +1,34 @@ +$ 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: but was: + +[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[12] -- Time elapsed: 0.030 s <<< FAILURE! +org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: but was: + +[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[24] -- Time elapsed: 0.011 s <<< FAILURE! +org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: but was: + +[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[34] -- Time elapsed: 0.008 s <<< FAILURE! +org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: but was: + +[ERROR] lab.instancio.FailingSeedDemoTest.everyAdultGetsADiscount(Person)[37] -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: age 18 should get a discount ==> expected: but was: + +[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: but was: +[ERROR] FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: but was: +[ERROR] FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: but was: +[ERROR] FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: but was: +[ERROR] FailingSeedDemoTest.everyAdultGetsADiscount:31 age 18 should get a discount ==> expected: but was: +[ERROR] Tests run: 40, Failures: 5, Errors: 0, Skipped: 0 diff --git a/instancio/output/18-reproduced-with-seed.txt b/instancio/output/18-reproduced-with-seed.txt new file mode 100644 index 0000000..5d0051f --- /dev/null +++ b/instancio/output/18-reproduced-with-seed.txt @@ -0,0 +1,10 @@ +$ 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: but was: +[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: but was: +[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) diff --git a/instancio/pom.xml b/instancio/pom.xml new file mode 100644 index 0000000..ea271ac --- /dev/null +++ b/instancio/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + com.ankurm.testing + instancio-lab + 1.0.0 + + + 25 + UTF-8 + 6.1.3 + 6.1.0 + 2.0.19 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + org.instancio + instancio-junit + ${instancio.version} + test + + + + + + + seed-log + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + + + diff --git a/instancio/scripts/capture-failure.sh b/instancio/scripts/capture-failure.sh new file mode 100755 index 0000000..cde297e --- /dev/null +++ b/instancio/scripts/capture-failure.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Records what Instancio prints when a test fails, and how to reproduce it with @Seed. +# FailingSeedDemoTest fails on purpose, so every mvn call here is allowed to exit non-zero. +set -uo pipefail +cd "$(dirname "$0")/.." +clean() { grep -v -E '^(Picked up JAVA_TOOL_OPTIONS|WARNING:)' | grep -v -E '^\[INFO\] (Total time|Finished at)'; } + +# 1. default classpath: no SLF4J provider, so the seed summary (logged via SLF4J) is dropped +RAW=$(mvn -B -T1 test -Dtest=FailingSeedDemoTest -Dlab.demo=fail 2>&1 | clean) +{ + echo '$ mvn test -Dtest=FailingSeedDemoTest -Dlab.demo=fail' + echo "$RAW" | sed -n '/Running lab.instancio.FailingSeedDemoTest/,/Tests run: 40, Failures: [0-9]*, Errors: 0, Skipped: 0$/p' \ + | grep -v -E '^\[INFO\] (BUILD|---|$)' | grep -v '^\s*at ' + echo "lines containing '@Seed' in the whole Maven output: $(echo "$RAW" | grep -c '@Seed')" +} > output/16-failing-test-without-slf4j.txt + +# 2. with an SLF4J provider (profile seed-log adds slf4j-simple) the summary appears +{ + echo '$ mvn test -Pseed-log -Dtest=FailingSeedDemoTest -Dlab.demo=fail' + mvn -B -T1 test -Pseed-log -Dtest=FailingSeedDemoTest -Dlab.demo=fail 2>&1 | clean \ + | sed -n '/Running lab.instancio.FailingSeedDemoTest/,/Tests run: 40, Failures: [0-9]*, Errors: 0, Skipped: 0$/p' \ + | grep -v -E '^\[INFO\] (BUILD|---|$)' | grep -v '^\s*at ' +} > output/17-failing-test-seed-summary.txt + +# 3. the same assertion, pinned with a seed copied from a summary like the one above +{ + echo '$ mvn test -Pseed-log -Dtest=ReproduceFailureDemoTest -Dlab.demo=reproduce (run 1)' + mvn -B -T1 test -Pseed-log -Dtest=ReproduceFailureDemoTest -Dlab.demo=reproduce 2>&1 | clean \ + | grep -E 'Tests run:.*in lab|AssertionFailedError|@Seed' | head -4 + echo '$ same command again (run 2)' + mvn -B -T1 test -Pseed-log -Dtest=ReproduceFailureDemoTest -Dlab.demo=reproduce 2>&1 | clean \ + | grep -E 'Tests run:.*in lab|AssertionFailedError|@Seed' | head -4 +} > output/18-reproduced-with-seed.txt +echo "captured: $(ls output | wc -l) files" diff --git a/instancio/scripts/run-all.sh b/instancio/scripts/run-all.sh new file mode 100755 index 0000000..3ea94ff --- /dev/null +++ b/instancio/scripts/run-all.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Regenerates every file in instancio/output/ (tests write the transcripts and assert the same facts). +set -euo pipefail +cd "$(dirname "$0")/.." +rm -f output/*.txt +mvn -B -q clean test +./scripts/capture-failure.sh +echo "done: $(ls output | wc -l) files in output/" diff --git a/instancio/src/main/java/lab/instancio/domain/Address.java b/instancio/src/main/java/lab/instancio/domain/Address.java new file mode 100644 index 0000000..755f8ab --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/Address.java @@ -0,0 +1,4 @@ +package lab.instancio.domain; + +public record Address(String street, String city, String postalCode, String country) { +} diff --git a/instancio/src/main/java/lab/instancio/domain/DiscountService.java b/instancio/src/main/java/lab/instancio/domain/DiscountService.java new file mode 100644 index 0000000..1094cd1 --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/DiscountService.java @@ -0,0 +1,19 @@ +package lab.instancio.domain; + +/** Tiny code under test, used by the failing-seed demo. */ +public final class DiscountService { + + private DiscountService() { + } + + /** Intended rule: 10 percent for ages 18 to 64, 20 percent for 65 and over, none under 18. */ + 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; + } +} diff --git a/instancio/src/main/java/lab/instancio/domain/Order.java b/instancio/src/main/java/lab/instancio/domain/Order.java new file mode 100644 index 0000000..e72b38e --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/Order.java @@ -0,0 +1,7 @@ +package lab.instancio.domain; + +import java.math.BigDecimal; +import java.util.UUID; + +public record Order(UUID id, BigDecimal total, OrderStatus status, PaymentMethod payment) { +} diff --git a/instancio/src/main/java/lab/instancio/domain/OrderStatus.java b/instancio/src/main/java/lab/instancio/domain/OrderStatus.java new file mode 100644 index 0000000..4e0f38c --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/OrderStatus.java @@ -0,0 +1,3 @@ +package lab.instancio.domain; + +public enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED } diff --git a/instancio/src/main/java/lab/instancio/domain/Page.java b/instancio/src/main/java/lab/instancio/domain/Page.java new file mode 100644 index 0000000..e0fc4cd --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/Page.java @@ -0,0 +1,7 @@ +package lab.instancio.domain; + +import java.util.List; + +/** A generic record: the type argument is erased at runtime, which is why TypeToken exists. */ +public record Page(List items, int pageNumber, long totalItems) { +} diff --git a/instancio/src/main/java/lab/instancio/domain/PaymentMethod.java b/instancio/src/main/java/lab/instancio/domain/PaymentMethod.java new file mode 100644 index 0000000..915fcfc --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/PaymentMethod.java @@ -0,0 +1,16 @@ +package lab.instancio.domain; + +import java.time.YearMonth; + +/** A sealed hierarchy: Instancio has to pick one of the permitted record subtypes. */ +public sealed interface PaymentMethod { + + record Card(String number, YearMonth expiry) implements PaymentMethod { + } + + record BankTransfer(String iban) implements PaymentMethod { + } + + record Cash() implements PaymentMethod { + } +} diff --git a/instancio/src/main/java/lab/instancio/domain/Person.java b/instancio/src/main/java/lab/instancio/domain/Person.java new file mode 100644 index 0000000..a9f8ccd --- /dev/null +++ b/instancio/src/main/java/lab/instancio/domain/Person.java @@ -0,0 +1,8 @@ +package lab.instancio.domain; + +import java.util.List; +import java.util.UUID; + +public record Person(UUID id, String name, String email, int age, + Address homeAddress, Address billingAddress, List orders) { +} diff --git a/instancio/src/test/java/lab/instancio/BasicsTest.java b/instancio/src/test/java/lab/instancio/BasicsTest.java new file mode 100644 index 0000000..ea5f486 --- /dev/null +++ b/instancio/src/test/java/lab/instancio/BasicsTest.java @@ -0,0 +1,93 @@ +package lab.instancio; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lab.instancio.domain.Person; +import org.instancio.Instancio; +import org.instancio.Select; +import org.instancio.settings.Keys; +import org.instancio.settings.Settings; +import org.junit.jupiter.api.Test; + +/** Output 01-03: the smallest thing that works, what "random" looks like, and how fields get values. */ +class BasicsTest { + + @Test + void createPopulatesTheWholeGraph() { + Person p = Instancio.create(Person.class); + + assertNotNull(p.id()); + assertNotNull(p.homeAddress()); + assertNotNull(p.billingAddress()); + assertTrue(p.orders().size() >= 2 && p.orders().size() <= 6, "default collection size is 2..6"); + + new Transcript("01-create-person.txt") + .line("// Instancio.create(Person.class) -- no seed pinned, so values differ on every run") + .line("id = (non-null: %s)", p.id() != null) + .line("name = <%d chars>", p.name().length()) + .line("email = <%d chars> (a random string, NOT an e-mail address)", p.email().length()) + .line("homeAddress non-null: %s", p.homeAddress() != null) + .line("billingAddress non-null: %s", p.billingAddress() != null) + .line("orders.size() = %d (default collection size is 2..6)", p.orders().size()) + .write(); + } + + @Test + void defaultsAreRandomNotRealistic() { + // Shape of the defaults, measured over 200 people with a fixed seed so the numbers are repeatable. + List people = Instancio.of(Person.class).withSeed(1L).stream().limit(200).toList(); + + int minAge = people.stream().mapToInt(Person::age).min().orElseThrow(); + int maxAge = people.stream().mapToInt(Person::age).max().orElseThrow(); + long negative = people.stream().filter(p -> p.age() < 0).count(); + int minName = people.stream().mapToInt(p -> p.name().length()).min().orElseThrow(); + int maxName = people.stream().mapToInt(p -> p.name().length()).max().orElseThrow(); + long emailsWithAt = people.stream().filter(p -> p.email().contains("@")).count(); + int minOrders = people.stream().mapToInt(p -> p.orders().size()).min().orElseThrow(); + int maxOrders = people.stream().mapToInt(p -> p.orders().size()).max().orElseThrow(); + + assertEquals(0, emailsWithAt, "default Strings are not e-mail shaped"); + + new Transcript("02-defaults-over-200-people.txt") + .line("// 200 people from Instancio.of(Person.class).withSeed(1L).stream()") + .line("age range : %d .. %d (%d negative)", minAge, maxAge, negative) + .line("name length range : %d .. %d", minName, maxName) + .line("emails containing '@': %d of 200", emailsWithAt) + .line("orders per person : %d .. %d", minOrders, maxOrders) + .line("sample name : %s", people.get(0).name()) + .line("sample email : %s", people.get(0).email()) + .write(); + } + + @Test + void otherEntryPoints() { + String s = Instancio.create(String.class); + List three = Instancio.ofList(Person.class).size(3).create(); + Set ints = Instancio.createSet(Integer.class); + Map map = Instancio.ofMap(String.class, Integer.class).size(2).create(); + Person blank = Instancio.createBlank(Person.class); + Person lenient = Instancio.of(Person.class) + .withSettings(Settings.create().set(Keys.COLLECTION_MIN_SIZE, 0).set(Keys.COLLECTION_MAX_SIZE, 0)) + .create(); + + assertEquals(3, three.size()); + assertEquals(2, map.size()); + + new Transcript("03-entry-points.txt") + .line("Instancio.create(String.class) -> %d chars", s.length()) + .line("Instancio.ofList(Person.class).size(3) -> list of %d", three.size()) + .line("Instancio.createSet(Integer.class) -> set of %d", ints.size()) + .line("Instancio.ofMap(String, Integer).size(2) -> map of %d", map.size()) + .line("Instancio.createBlank(Person.class) -> id=%s name=%s age=%d orders=%s", + blank.id(), blank.name(), blank.age(), blank.orders()) + .line("collection size pinned to 0 via Settings -> orders=%s", lenient.orders()) + .line("distinct names among the 3: %d", three.stream().map(Person::name).collect(Collectors.toSet()).size()) + .write(); + } +} diff --git a/instancio/src/test/java/lab/instancio/CustomiseTest.java b/instancio/src/test/java/lab/instancio/CustomiseTest.java new file mode 100644 index 0000000..eee27c6 --- /dev/null +++ b/instancio/src/test/java/lab/instancio/CustomiseTest.java @@ -0,0 +1,165 @@ +package lab.instancio; + +import static org.instancio.Select.all; +import static org.instancio.Select.field; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import java.util.regex.Pattern; +import lab.instancio.domain.Address; +import lab.instancio.domain.Order; +import lab.instancio.domain.OrderStatus; +import lab.instancio.domain.PaymentMethod; +import lab.instancio.domain.Person; +import org.instancio.Instancio; +import org.junit.jupiter.api.Test; + +/** Output 04-08: set, generate, supply, ignore, withNullable, subtype, and the selectors that target them. */ +class CustomiseTest { + + private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[a-z]+$"); + + @Test + void setAndGenerateAFewFields() { + 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(); + + assertEquals("Asha Kulkarni", p.name()); + assertTrue(EMAIL.matcher(p.email()).matches(), p.email()); + assertTrue(p.age() >= 18 && p.age() <= 65); + assertTrue(p.homeAddress().postalCode().matches("\\d{6}")); + assertEquals("IN", p.billingAddress().country(), "field(Address::country) hits BOTH addresses"); + + new Transcript("04-set-and-generate.txt") + .line("name = %s", p.name()) + .line("email matches e-mail regex = %s", EMAIL.matcher(p.email()).matches()) + .line("age in [18, 65] = %s", p.age() >= 18 && p.age() <= 65) + .line("homeAddress.postalCode matches \\d{6} = %s", p.homeAddress().postalCode().matches("\\d{6}")) + .line("homeAddress.country = %s", p.homeAddress().country()) + .line("billingAddress.country = %s // one selector, two matching fields", p.billingAddress().country()) + .write(); + } + + @Test + void supplyIgnoreAndNullable() { + 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(); + + assertEquals("customer-1", p.name()); + assertNull(p.billingAddress(), "ignore() leaves the default: null for a reference type"); + + // ignore() on a primitive leaves 0, and ignore() on a record component can only produce the type's default. + Person noAge = Instancio.of(Person.class).ignore(field(Person::age)).create(); + assertEquals(0, noAge.age()); + + // withNullable: across 200 people some homeAddress values are null and some are not (fixed seed, repeatable). + List people = Instancio.of(Person.class) + .withNullable(field(Person::homeAddress)).withSeed(7L).stream().limit(200).toList(); + long nulls = people.stream().filter(x -> x.homeAddress() == null).count(); + assertTrue(nulls > 0 && nulls < 200); + + new Transcript("05-supply-ignore-nullable.txt") + .line("supply(name, () -> \"customer-\" + n) -> %s", p.name()) + .line("ignore(billingAddress) -> %s", p.billingAddress()) + .line("ignore(age) (an int) -> %d", noAge.age()) + .line("withNullable(homeAddress), 200 people, seed 7 -> %d null, %d non-null", nulls, 200 - nulls) + .write(); + } + + @Test + void selectorsHitMoreThanOneField() { + // 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(); + + assertEquals("Pune", p.homeAddress().city()); + assertEquals("Pune", p.billingAddress().city()); + assertTrue(p.orders().stream().allMatch(o -> o.status() == OrderStatus.PAID)); + assertTrue(p.orders().stream().allMatch(o -> o.total().compareTo(new BigDecimal("499.00")) == 0)); + + new Transcript("06-selectors.txt") + .line("set(all(Address.class), pune) -> home=%s, billing=%s", + p.homeAddress().city(), p.billingAddress().city()) + .line("set(field(Order::status), PAID) -> statuses of %d orders: %s", p.orders().size(), + p.orders().stream().map(Order::status).distinct().toList()) + .line("set(field(Order::total), 499.00) -> totals: %s", + p.orders().stream().map(Order::total).distinct().toList()) + .write(); + } + + @Test + void subtypeForSealedAndInterfaceFields() { + Order o = Instancio.of(Order.class) + .subtype(field(Order::payment), PaymentMethod.Card.class) + .create(); + assertTrue(o.payment() instanceof PaymentMethod.Card); + + Order cash = Instancio.of(Order.class) + .set(field(Order::payment), new PaymentMethod.Cash()) + .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(); + PaymentMethod.Card c = (PaymentMethod.Card) card.payment(); + assertEquals(16, c.number().length()); + assertNotNull(c.expiry()); + + new Transcript("07-subtype.txt") + .line("subtype(payment, Card.class) -> %s", o.payment().getClass().getSimpleName()) + .line("set(payment, new Cash()) -> %s", cash.payment().getClass().getSimpleName()) + .line("card number length -> %d", c.number().length()) + .line("card expiry is in the future -> %s", c.expiry().isAfter(java.time.YearMonth.now())) + .write(); + } + + @Test + void unusedSelectorsAreAnErrorByDefault() { + // Strict mode: a selector that matches nothing is reported, which catches typos and stale tests. + String typo = failureOf(() -> Instancio.of(Person.class).ignore(field(Person.class, "doesNotExist")).create()); + String unused = failureOf(() -> Instancio.of(Address.class).set(field(Order::status), OrderStatus.PAID).create()); + assertTrue(unused.startsWith("org.instancio.exception.UnusedSelectorException"), unused); + + // lenient() switches the check off for this one call + Address lenient = Instancio.of(Address.class).lenient().set(field(Order::status), OrderStatus.PAID).create(); + assertNotNull(lenient); + + new Transcript("08-unused-selector.txt") + .block("field(Person.class, \"doesNotExist\"), first 8 lines:", firstLines(typo, 8)) + .block("set(field(Order::status), ..) on an Address graph, first 8 lines:", firstLines(unused, 8)) + .line("with .lenient() the same call succeeds: %s", lenient != null) + .write(); + } + + private static String failureOf(Runnable r) { + try { + r.run(); + return "no exception"; + } catch (RuntimeException e) { + return e.getClass().getName() + ": " + e.getMessage(); + } + } + + private static String firstLines(String text, int n) { + return String.join("\n", java.util.Arrays.stream(text.split("\n")).limit(n).toList()); + } +} diff --git a/instancio/src/test/java/lab/instancio/FailingSeedDemoTest.java b/instancio/src/test/java/lab/instancio/FailingSeedDemoTest.java new file mode 100644 index 0000000..59b3885 --- /dev/null +++ b/instancio/src/test/java/lab/instancio/FailingSeedDemoTest.java @@ -0,0 +1,34 @@ +package lab.instancio; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import lab.instancio.domain.DiscountService; +import lab.instancio.domain.Person; +import org.instancio.junit.Given; +import org.instancio.junit.InstancioExtension; +import org.instancio.junit.WithSettings; +import org.instancio.settings.Keys; +import org.instancio.settings.Settings; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Deliberately fails (DiscountService has an off-by-one for age 18), so it only runs when + * -Dlab.demo=fail is passed. scripts/capture-failure.sh runs it and records Instancio's real failure message. + */ +@EnabledIfSystemProperty(named = "lab.demo", matches = "fail") +@ExtendWith(InstancioExtension.class) +class FailingSeedDemoTest { + + @WithSettings + private final Settings settings = Settings.create() + .set(Keys.INTEGER_MIN, 18) + .set(Keys.INTEGER_MAX, 30); + + @RepeatedTest(40) + void everyAdultGetsADiscount(@Given Person adult) { + assertTrue(DiscountService.discountPercent(adult.age()) > 0, + "age " + adult.age() + " should get a discount"); + } +} diff --git a/instancio/src/test/java/lab/instancio/GenericsAndSealedTest.java b/instancio/src/test/java/lab/instancio/GenericsAndSealedTest.java new file mode 100644 index 0000000..2ca0988 --- /dev/null +++ b/instancio/src/test/java/lab/instancio/GenericsAndSealedTest.java @@ -0,0 +1,105 @@ +package lab.instancio; + +import static org.instancio.Select.all; +import static org.instancio.Select.field; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import lab.instancio.domain.Order; +import lab.instancio.domain.Page; +import lab.instancio.domain.PaymentMethod; +import lab.instancio.domain.Person; +import org.instancio.Instancio; +import org.instancio.TypeToken; +import org.junit.jupiter.api.Test; + +/** Output 12-14: records, sealed hierarchies and generic types. */ +class GenericsAndSealedTest { + + @Test + void sealedInterfaceYieldsEveryPermittedSubtype() { + Map counts = Instancio.of(PaymentMethod.class).withSeed(11L).stream().limit(300) + .collect(Collectors.groupingBy(p -> p.getClass().getSimpleName(), TreeMap::new, Collectors.counting())); + + assertEquals(3, counts.size(), counts.toString()); + assertTrue(counts.values().stream().allMatch(c -> c > 50), counts.toString()); + + new Transcript("12-sealed.txt") + .line("// 300 x Instancio.of(PaymentMethod.class).withSeed(11L) (sealed: Card, BankTransfer, Cash)") + .line("subtypes seen: %s", counts) + .write(); + } + + @Test + void genericRecordNeedsATypeToken() { + Page page = Instancio.of(new TypeToken>() {}) + .generate(field(Page.class, "pageNumber"), gen -> gen.ints().range(1, 10)) + .size(field(Page.class, "items"), 4) + .create(); + + assertEquals(4, page.items().size()); + assertTrue(page.items().get(0) instanceof Person); + + Map> ordersByCustomer = Instancio.create(new TypeToken>>() {}); + assertFalse(ordersByCustomer.isEmpty()); + Object firstValueElement = ordersByCustomer.values().iterator().next().get(0); + assertTrue(firstValueElement instanceof Order); + + // Without a TypeToken the raw class loses T: Instancio.create(Page.class) cannot know the item type. + String rawMessage = rawFailureMessage(); + + new Transcript("13-type-token.txt") + .line("Instancio.of(new TypeToken>() {}).size(items, 4)") + .line(" items.size() = %d", page.items().size()) + .line(" items.get(0) is a Person = %s", page.items().get(0) instanceof Person) + .line(" pageNumber in [1,10] = %s", page.pageNumber() >= 1 && page.pageNumber() <= 10) + .line("Instancio.create(new TypeToken>>() {})") + .line(" entries = %d, first value element is an Order = %s", + ordersByCustomer.size(), firstValueElement instanceof Order) + .block("Instancio.create(Page.class) (raw class, T unknown), first 8 lines of the message:", rawMessage) + .write(); + } + + @Test + void recordsAreBuiltThroughTheCanonicalConstructor() { + // Records have no setters and final fields; Instancio uses the canonical constructor, so + // a compact-constructor invariant is respected or reported. + record Percent(int value) { + Percent { + if (value < 0 || value > 100) { + throw new IllegalArgumentException("value out of range: " + value); + } + } + } + List defaults = Instancio.of(Percent.class).withSeed(1L).stream().limit(20).toList(); + long nulls = defaults.stream().filter(java.util.Objects::isNull).count(); + List withRange = Instancio.of(Percent.class) + .generate(all(int.class), gen -> gen.ints().range(0, 100)).stream().limit(20).toList(); + long nullsWithRange = withRange.stream().filter(java.util.Objects::isNull).count(); + assertEquals(0, nullsWithRange); + assertTrue(nulls > 0, "a constructor that throws does not fail the build: the element is null"); + + new Transcript("14-record-invariant.txt") + .line("record Percent(int value) { compact ctor rejects value < 0 or > 100 }") + .line("Instancio.of(Percent.class).withSeed(1L), 20 instances, default ints 1..10000:") + .line(" no exception thrown; null elements: %d of 20", nulls) + .line("with generate(all(int.class), ints().range(0, 100)):") + .line(" null elements: %d of 20, first values: %s", nullsWithRange, withRange.subList(0, 3)) + .write(); + } + + private static String rawFailureMessage() { + try { + Instancio.create(Page.class); + return "no exception"; + } catch (RuntimeException e) { + return String.join("\n", java.util.Arrays.stream(e.getMessage().strip().split("\n")).limit(8).toList()); + } + } +} diff --git a/instancio/src/test/java/lab/instancio/JUnitExtensionTest.java b/instancio/src/test/java/lab/instancio/JUnitExtensionTest.java new file mode 100644 index 0000000..0cdcd4a --- /dev/null +++ b/instancio/src/test/java/lab/instancio/JUnitExtensionTest.java @@ -0,0 +1,106 @@ +package lab.instancio; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import lab.instancio.domain.Person; +import org.instancio.Instancio; +import org.instancio.junit.Given; +import org.instancio.junit.InstancioExtension; +import org.instancio.junit.Seed; +import org.instancio.junit.WithSettings; +import org.instancio.settings.Keys; +import org.instancio.settings.Settings; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Output 15-18: the JUnit 6 integration. Values come from the extension, settings from @WithSettings. */ +@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; + + private static final List LOG = new ArrayList<>(); + + @Test + void settingsAndFieldInjection(@Given Person param) { + assertTrue(injectedField.orders().size() <= 3, "@WithSettings applies to @Given values"); + assertTrue(param.age() >= 18 && param.age() <= 90); + assertNotEquals(injectedField, param); + LOG.add("@Given field and parameter: orders " + injectedField.orders().size() + " and " + param.orders().size() + + ", ages in [18, 90]: " + (param.age() >= 18 && param.age() <= 90)); + } + + private static final List SEEDED = new ArrayList<>(); + + @Seed(2026) + @Test + void seedAnnotationPinsTheDataA(@Given Person p) { + SEEDED.add(p); + } + + @Seed(2026) + @Test + void seedAnnotationPinsTheDataB(@Given Person p) { + SEEDED.add(p); + } + + @Seed(2027) + @Test + void otherSeed(@Given Person p) { + SEEDED.add(p); + } + + @RepeatedTest(5) + void repeatedTestGetsFreshDataEachTime(@Given Person p) { + assertTrue(p.age() >= 18); + } + + @ParameterizedTest + @ValueSource(strings = {"IN", "US", "DE"}) + void valueSourcePlusGivenParameter(String country, @Given Person p) { + assertTrue(country.length() == 2); + assertTrue(p.age() >= 18); + LOG.add("@ParameterizedTest [" + country + "] gets its own Person, age " + p.age()); + } + + static Stream people() { + return Instancio.of(Person.class).withSeed(100L).stream().limit(3); + } + + @ParameterizedTest + @MethodSource("people") + void methodSourceBackedByInstancioStream(Person p) { + assertTrue(p.orders().size() >= 2, "no @WithSettings in effect for a static source: default sizes 2..6"); + LOG.add("@MethodSource Instancio.stream(): " + p.name() + " with " + p.orders().size() + " orders"); + } + + @org.junit.jupiter.api.AfterAll + static void writeTranscript() { + Transcript t = new Transcript("15-junit-extension.txt"); + t.line("// instancio-junit 6.1.0 on JUnit Jupiter 6.1.3, lines collected inside the tests"); + long distinct = SEEDED.stream().distinct().count(); + assertEquals(2, distinct, "two methods with @Seed(2026) get the same Person; @Seed(2027) differs"); + LOG.add("@Seed(2026) on two different test methods + @Seed(2027) on a third: " + SEEDED.size() + + " Persons, " + distinct + " distinct"); + LOG.stream().sorted().forEach(t::line); + t.write(); + } +} diff --git a/instancio/src/test/java/lab/instancio/ReproduceFailureDemoTest.java b/instancio/src/test/java/lab/instancio/ReproduceFailureDemoTest.java new file mode 100644 index 0000000..dc517bf --- /dev/null +++ b/instancio/src/test/java/lab/instancio/ReproduceFailureDemoTest.java @@ -0,0 +1,36 @@ +package lab.instancio; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import lab.instancio.domain.DiscountService; +import lab.instancio.domain.Person; +import org.instancio.junit.Given; +import org.instancio.junit.InstancioExtension; +import org.instancio.junit.Seed; +import org.instancio.junit.WithSettings; +import org.instancio.settings.Keys; +import org.instancio.settings.Settings; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * The same assertion as FailingSeedDemoTest, but as a plain @Test pinned with a seed copied from the + * failure summary in output/17-failing-test-seed-summary.txt. It fails every time, on the same Person. + */ +@EnabledIfSystemProperty(named = "lab.demo", matches = "reproduce") +@ExtendWith(InstancioExtension.class) +class ReproduceFailureDemoTest { + + @WithSettings + private final Settings settings = Settings.create() + .set(Keys.INTEGER_MIN, 18) + .set(Keys.INTEGER_MAX, 30); + + @Seed(2016013118612999037L) + @Test + void everyAdultGetsADiscount(@Given Person adult) { + assertTrue(DiscountService.discountPercent(adult.age()) > 0, + "age " + adult.age() + " should get a discount"); + } +} diff --git a/instancio/src/test/java/lab/instancio/SettingsAndSeedTest.java b/instancio/src/test/java/lab/instancio/SettingsAndSeedTest.java new file mode 100644 index 0000000..71d3bde --- /dev/null +++ b/instancio/src/test/java/lab/instancio/SettingsAndSeedTest.java @@ -0,0 +1,103 @@ +package lab.instancio; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.IntStream; +import lab.instancio.domain.Person; +import org.instancio.Instancio; +import org.instancio.settings.Keys; +import org.instancio.settings.Settings; +import org.instancio.settings.StringCase; +import org.instancio.settings.StringType; +import org.junit.jupiter.api.Test; + +/** Output 09-12: Settings (sizes, string lengths) and seeds (identical output, different output, streams). */ +class SettingsAndSeedTest { + + @Test + void settingsControlSizesAndStrings() { + 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 people = Instancio.of(Person.class).withSettings(settings).withSeed(3L).stream().limit(100).toList(); + + int minOrders = people.stream().mapToInt(p -> p.orders().size()).min().orElseThrow(); + int maxOrders = people.stream().mapToInt(p -> p.orders().size()).max().orElseThrow(); + int minName = people.stream().mapToInt(p -> p.name().length()).min().orElseThrow(); + int maxName = people.stream().mapToInt(p -> p.name().length()).max().orElseThrow(); + int minAge = people.stream().mapToInt(Person::age).min().orElseThrow(); + int maxAge = people.stream().mapToInt(Person::age).max().orElseThrow(); + boolean allLower = people.stream().allMatch(p -> p.name().equals(p.name().toLowerCase())); + + assertTrue(minOrders >= 1 && maxOrders <= 2); + assertTrue(minName >= 8 && maxName <= 12); + assertTrue(minAge >= 18 && maxAge <= 80); + assertTrue(allLower); + + new Transcript("09-settings.txt") + .line("// 100 people, Settings: collection 1..2, string length 8..12, lower-case alphabetic, int 18..80") + .line("orders per person : %d .. %d", minOrders, maxOrders) + .line("name length : %d .. %d", minName, maxName) + .line("age : %d .. %d", minAge, maxAge) + .line("all names lower-case: %s", allLower) + .line("sample name : %s", people.get(0).name()) + .write(); + } + + @Test + void sameSeedGivesIdenticalObjects() { + 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); + + // The seed can also live in Settings + Person d = Instancio.of(Person.class).withSettings(Settings.create().set(Keys.SEED, 42L)).create(); + assertEquals(a, d); + + new Transcript("10-seed-reproducibility.txt") + .line("create with seed 42, first call : %s", a.name()) + .line("create with seed 42, second call: %s", b.name()) + .line("a.equals(b) (whole object graph): %s", a.equals(b)) + .line("a.hashCode() == b.hashCode() : %s", a.hashCode() == b.hashCode()) + .line("create with seed 43 : %s", c.name()) + .line("a.equals(c) : %s", a.equals(c)) + .line("seed 42 set through Keys.SEED : equals a = %s", a.equals(d)) + .write(); + } + + @Test + void streamsAreReproducibleAndNoSeedIsNot() { + List first = Instancio.of(Person.class).withSeed(5L).stream().limit(5).map(Person::name).toList(); + List second = Instancio.of(Person.class).withSeed(5L).stream().limit(5).map(Person::name).toList(); + assertEquals(first, second); + + long distinctWithoutSeed = IntStream.range(0, 5) + .mapToObj(i -> Instancio.create(Person.class).name()).distinct().count(); + assertEquals(5, distinctWithoutSeed); + + // Instancio's own Random honours the seed too + Person p = Instancio.of(Person.class).withSeed(9L).create(); + Person q = Instancio.of(Person.class).withSeed(9L).create(); + + new Transcript("11-seed-streams.txt") + .line("stream, seed 5, run 1: %s", first) + .line("stream, seed 5, run 2: %s", second) + .line("identical : %s", first.equals(second)) + .line("5 creates without a seed -> %d distinct names", distinctWithoutSeed) + .line("seed 9 twice -> same orders list: %s", p.orders().equals(q.orders())) + .write(); + } +} diff --git a/instancio/src/test/java/lab/instancio/Transcript.java b/instancio/src/test/java/lab/instancio/Transcript.java new file mode 100644 index 0000000..cb8448f --- /dev/null +++ b/instancio/src/test/java/lab/instancio/Transcript.java @@ -0,0 +1,57 @@ +package lab.instancio; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Collects lines during a test and writes them to output/NN-name.txt. + * The same test asserts the same facts, so every figure in the blog post is an assertion. + */ +final class Transcript { + private final String file; + private final List lines = new ArrayList<>(); + + Transcript(String file) { + this.file = file; + } + + Transcript line(String format, Object... args) { + lines.add(args.length == 0 ? format : String.format(format, args)); + return this; + } + + /** A labelled multi-line message (an AssertionError message, usually), indented, unedited. */ + Transcript block(String label, String text) { + lines.add(label); + for (String l : text.strip().split("\n")) { + lines.add(" " + l.stripTrailing()); + } + return this; + } + + /** Like block(), but drops stack-frame lines ("at ..." and "...(n remaining lines...)"). */ + Transcript blockWithoutFrames(String label, String text) { + lines.add(label + " (stack frames omitted)"); + for (String l : text.strip().split("\n")) { + if (l.stripLeading().startsWith("at ") || l.stripLeading().startsWith("...")) { + continue; + } + lines.add(" " + l.stripTrailing()); + } + return this; + } + + void write() { + try { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +}