Build CoreOrdered learning track

Property-Based Testing with jqwik

Learn Java Formal Methods, Testing, Benchmarking, and Performance Engineering - Part 011

Property-based testing dengan jqwik untuk Java engineer: mental model, properties, arbitraries, shrinking, classification, deterministic replay, oracle design, dan strategi penerapan ke production codebase.

9 min read1673 words
PrevNext
Lesson 1140 lesson track0922 Build Core
#java#testing#property-based-testing#jqwik+3 more

Part 011 — Property-Based Testing with jqwik

Tujuan bagian ini: mengubah cara kita menulis test dari “saya punya beberapa contoh input” menjadi “saya tahu hukum yang harus selalu benar”. Property-based testing bukan pengganti unit test. Ia adalah lapisan verification yang menangkap ruang input luas, edge case tak terduga, dan kelemahan oracle test.

Di test biasa, kita menulis contoh:

@Test
void discountCannotMakeTotalNegative() {
    Money total = Pricing.applyDiscount(Money.of("100.00"), Discount.percent(10));

    assertThat(total).isEqualTo(Money.of("90.00"));
}

Test ini berguna. Tapi ia hanya membuktikan satu titik.

Pertanyaan production-grade-nya:

Untuk semua harga valid,
untuk semua diskon valid,
apakah hasilnya selalu tidak negatif?

Apakah rounding stabil?
Apakah apply discount idempotent untuk discount 0%?
Apakah total selalu <= subtotal?
Apakah property tetap benar ketika ada tax, voucher, dan currency scale berbeda?

Property-based testing mencoba memalsukan klaim kita dengan banyak input yang dihasilkan otomatis.

Mental model-nya:

example-based test = verify known cases
property-based test = search for counterexamples

Kalau property gagal, framework akan mencoba memberi contoh minimal yang masih gagal. Proses ini disebut shrinking.


1. Why Property-Based Testing Exists

Example-based tests punya bias manusia.

Kita cenderung memilih input yang:

  • terlihat normal;
  • mudah dihitung manual;
  • sesuai happy path;
  • tidak terlalu besar;
  • tidak terlalu kosong;
  • tidak terlalu ekstrem;
  • tidak mencerminkan kombinasi aneh production data.

Contoh buruk:

@Test
void normalizePhoneNumber() {
    assertThat(normalize("0812-3456-7890")).isEqualTo("6281234567890");
}

Production tidak akan sebaik itu.

Input nyata bisa:

""
" "
"+62 812 3456 7890"
"006281234567890"
"0812.3456.7890"
"(021) 123-456"
"+62-812-3456-7890 ext 12"
"abc"
"081234567890"  // full-width unicode digits

Kalau fungsi punya domain input besar, beberapa contoh tidak cukup.

Property-based testing membantu ketika:

input space large
edge cases hard to enumerate
domain has invariants
operation has algebraic laws
parser/serializer roundtrip exists
state transition can be modeled
bug tends to hide in combinations

2. jqwik Mental Model

jqwik adalah property-based testing engine untuk JVM yang terintegrasi dengan JUnit Platform. Test property ditulis dengan annotation @Property, input dihasilkan dari parameter @ForAll, dan custom generator dibuat dengan Arbitrary<T>.

Bentuk minimal:

import net.jqwik.api.ForAll;
import net.jqwik.api.Property;

class StringProperties {

    @Property
    boolean reversingTwiceReturnsOriginal(@ForAll String value) {
        return new StringBuilder(value).reverse()
            .reverse()
            .toString()
            .equals(value);
    }
}

Flow eksekusi:

Yang paling penting: @Property bukan “test dengan random input saja”. Ia adalah executable specification.


3. The Difference Between Example and Property

Example:

@Test
void sortedListExample() {
    List<Integer> result = Sorter.sort(List.of(3, 1, 2));

    assertThat(result).containsExactly(1, 2, 3);
}

Property:

@Property
void sortingPreservesSize(@ForAll List<Integer> input) {
    List<Integer> result = Sorter.sort(input);

    assertThat(result).hasSize(input.size());
}

@Property
void sortingReturnsOrderedList(@ForAll List<Integer> input) {
    List<Integer> result = Sorter.sort(input);

    assertThat(result).isSorted();
}

@Property
void sortingPreservesElements(@ForAll List<Integer> input) {
    List<Integer> result = Sorter.sort(input);

    assertThat(result)
        .containsExactlyInAnyOrderElementsOf(input);
}

Satu contoh “3,1,2” tidak cukup untuk membuktikan sorting.

Tiga property di atas menangkap struktur sorting:

same cardinality
ordered output
same multiset of elements

Itu bukan sekadar “expected output”. Itu hukum operasi.


4. Property Vocabulary for Java Engineers

Property yang bagus biasanya masuk salah satu kategori ini:

Property TypeMeaningExample
InvariantKondisi yang selalu benarbalance never negative
Round-tripencode/decode kembali ke nilai awalparse(format(x)) == x
Idempotenceoperasi berulang punya efek samanormalize(normalize(x)) == normalize(x)
Commutativityurutan tidak pentinga + b == b + a
Associativitygrouping tidak penting(a+b)+c == a+(b+c)
Monotonicityarah perubahan stabilhigher discount means lower total
Conservationsesuatu tidak hilang/bertambahsorted list has same elements
Metamorphicperubahan input punya relasi outputadding whitespace does not change parsed number
Oracle equivalencedibandingkan dengan implementation trustedfast algorithm == slow reference algorithm
State transitionsetiap command menjaga invariantvalid workflow never enters illegal state

Jangan mulai dari jqwik annotation.

Mulai dari kalimat:

For all <valid inputs>, when <operation>, then <property> must hold.

Contoh:

For all valid order lines, when calculating subtotal, then subtotal equals sum(quantity * unitPrice).

For all valid draft cases, when submitted, then status becomes SUBMITTED and submittedAt is set exactly once.

For all generated payloads, when serialize then deserialize, then semantic value is preserved.

5. Property-Based Testing Is Not Random Testing

Random testing tanpa model hanya berharap bug muncul.

Property-based testing punya tiga pilar:

generator + property + oracle

Generator menentukan input yang valid dan relevan.

Property menentukan klaim yang diuji.

Oracle menentukan cara memutuskan benar/salah.

Bug paling umum dalam PBT bukan framework-nya, tapi:

generator too broad
property too weak
oracle mirrors implementation bug
assumptions filter too much data
shrinking broken by invalid constraints

6. Dependency Setup

Dengan Maven:

<dependency>
    <groupId>net.jqwik</groupId>
    <artifactId>jqwik</artifactId>
    <version>${jqwik.version}</version>
    <scope>test</scope>
</dependency>

Dengan Gradle:

dependencies {
    testImplementation("net.jqwik:jqwik:${jqwikVersion}")
}

tasks.test {
    useJUnitPlatform()
}

Karena jqwik berjalan sebagai JUnit Platform engine, ia bisa hidup berdampingan dengan JUnit Jupiter.

Pattern layout:

src/test/java
  com.company.pricing
    PriceCalculatorTest.java        // example-based tests
    PriceCalculatorProperties.java  // property-based tests
    PricingArbitraries.java         // domain generators

Naming convention yang saya rekomendasikan:

<X>Test           = example-based tests
<X>Properties     = property-based tests
<X>Arbitraries    = generator DSL
<X>Fixtures       = deterministic fixtures
<X>Assertions     = custom assertions

7. Basic jqwik Anatomy

Contoh:

import net.jqwik.api.*;
import org.assertj.core.api.Assertions;

class MoneyProperties {

    @Property
    void addingZeroDoesNotChangeValue(@ForAll("validMoney") Money money) {
        Money result = money.plus(Money.zero(money.currency()));

        Assertions.assertThat(result).isEqualTo(money);
    }

    @Provide
    Arbitrary<Money> validMoney() {
        Arbitrary<String> currency = Arbitraries.of("IDR", "USD", "EUR");
        Arbitrary<BigDecimal> amount = Arbitraries.bigDecimals()
            .between(BigDecimal.ZERO, new BigDecimal("1000000.00"))
            .ofScale(2);

        return Combinators.combine(currency, amount)
            .as((c, a) -> new Money(c, a));
    }
}

Komponen penting:

jqwik ConceptRole
@Propertymethod adalah property test
@ForAllparameter diisi generator
@Providenamed arbitrary provider
Arbitrary<T>generator + shrinking strategy
Combinators.combinemembangun object dari beberapa arbitrary
assertionoracle

8. Properties Should Be Strong Enough to Fail Bad Code

Property lemah:

@Property
void totalIsNotNull(@ForAll("orders") Order order) {
    assertThat(calculator.total(order)).isNotNull();
}

Ini hampir tidak berguna.

Property kuat:

@Property
void totalEqualsSumOfLineTotalsMinusDiscountPlusTax(
    @ForAll("pricedOrders") Order order
) {
    Money expected = order.lines().stream()
        .map(line -> line.unitPrice().multiply(line.quantity()))
        .reduce(Money.zero(order.currency()), Money::plus)
        .minus(order.discountAmount())
        .plus(order.taxAmount());

    assertThat(calculator.total(order)).isEqualTo(expected);
}

Tapi hati-hati: kalau expected memakai logic yang sama dengan production implementation, kita hanya menggandakan bug.

Lebih baik:

production implementation = optimized, integrated, complex
reference oracle = simple, slow, obvious, independent

Contoh sorting:

@Property
void customSortMatchesJdkSort(@ForAll List<Integer> input) {
    List<Integer> expected = new ArrayList<>(input);
    expected.sort(Integer::compareTo);

    List<Integer> actual = CustomSorter.sort(input);

    assertThat(actual).containsExactlyElementsOf(expected);
}

9. Generator Design: The Real Skill

Property-based testing sering gagal bukan karena property-nya, tapi karena generator-nya buruk.

Generator buruk:

@Property
void validEmailCanBeParsed(@ForAll String email) {
    Email.parse(email);
}

Masalah: kebanyakan string bukan email valid. Test ini akan gagal atau penuh assumption discard.

Generator baik:

@Provide
Arbitrary<String> validEmails() {
    Arbitrary<String> localPart = Arbitraries.strings()
        .withChars('a', 'b', 'c', 'd', 'e', 'f')
        .ofMinLength(1)
        .ofMaxLength(20);

    Arbitrary<String> domain = Arbitraries.of("example.com", "company.test", "mail.local");

    return Combinators.combine(localPart, domain)
        .as((local, d) -> local + "@" + d);
}

Production generator harus mencerminkan domain constraints.

Bukan:

random object graph

Tapi:

valid object graph distributed across meaningful equivalence classes

10. Generator Taxonomy

Untuk codebase besar, susun generator sebagai layer.

10.1 Primitive Arbitraries

static Arbitrary<BigDecimal> positiveAmount() {
    return Arbitraries.bigDecimals()
        .between(new BigDecimal("0.01"), new BigDecimal("1000000.00"))
        .ofScale(2);
}

10.2 Value Object Arbitraries

static Arbitrary<Money> money(String currency) {
    return positiveAmount().map(amount -> new Money(currency, amount));
}

10.3 Entity Arbitraries

static Arbitrary<OrderLine> orderLine(String currency) {
    return Combinators.combine(
        sku(),
        Arbitraries.integers().between(1, 100),
        money(currency)
    ).as(OrderLine::new);
}

10.4 Aggregate Arbitraries

static Arbitrary<Order> order() {
    Arbitrary<String> currency = Arbitraries.of("IDR", "USD");

    return currency.flatMap(c ->
        orderLine(c).list().ofMinSize(1).ofMaxSize(20)
            .map(lines -> Order.draft(OrderId.newId(), c, lines))
    );
}

10.5 Command Arbitraries

static Arbitrary<OrderCommand> commandFor(OrderState state) {
    return switch (state) {
        case DRAFT -> Arbitraries.of(new SubmitOrder(), new CancelOrder());
        case SUBMITTED -> Arbitraries.of(new ApproveOrder(), new RejectOrder());
        case APPROVED -> Arbitraries.of(new FulfillOrder());
        default -> Arbitraries.of();
    };
}

10.6 Trace Arbitraries

Trace generator menghasilkan urutan command.

Draft -> Submit -> Approve -> Fulfill
Draft -> Cancel
Draft -> Submit -> Reject
Draft -> Submit -> Approve -> Cancel? invalid

Trace lebih cocok dibahas di Part 012, karena domain model stateful butuh generator yang sadar state.


11. Avoid Overusing Assume

jqwik punya mechanism assumption untuk membuang input yang tidak memenuhi precondition.

Contoh:

@Property
void divisionRoundtrip(@ForAll int a, @ForAll int b) {
    Assume.that(b != 0);

    assertThat((a / b) * b + (a % b)).isEqualTo(a);
}

Ini valid.

Tapi kalau assumption membuang terlalu banyak data, generator salah.

Buruk:

@Property
void validUserCanRegister(@ForAll User user) {
    Assume.that(user.email().isValid());
    Assume.that(user.age() >= 18);
    Assume.that(user.country().isSupported());
    Assume.that(user.username().length() >= 3);

    registration.register(user);
}

Lebih baik buat generator valid user:

@Provide
Arbitrary<User> validAdultUsers() {
    return Combinators.combine(
        validEmail(),
        Arbitraries.integers().between(18, 100),
        supportedCountry(),
        validUsername()
    ).as(User::new);
}

Rule:

Assumptions are for rare preconditions.
Generators are for domain constraints.

12. Shrinking: The Debugging Superpower

Ketika property gagal, input awal bisa sangat kompleks.

Contoh:

Order(
  currency=IDR,
  lines=[... 17 lines ...],
  discount=DiscountRule(...),
  tax=TaxRule(...)
)

Shrinking mencoba mencari input minimal yang masih gagal:

Order(
  currency=IDR,
  lines=[OrderLine(sku=A, quantity=1, unitPrice=0.01)],
  discount=100%,
  tax=0
)

Ini sangat penting karena counterexample kecil dapat langsung dijadikan regression test.

Pattern:

@Test
void regression_discount100PercentDoesNotCreateNegativeRounding() {
    Order order = Order.draft(
        OrderId.of("regression-2026-07-02"),
        "IDR",
        List.of(new OrderLine("A", 1, Money.of("IDR", "0.01")))
    ).withDiscount(Discount.percent(new BigDecimal("100.00")));

    assertThat(calculator.total(order)).isEqualTo(Money.zero("IDR"));
}

PBT menemukan counterexample. Example test menguncinya sebagai regression test yang mudah dibaca.


13. Deterministic Replay

Property test memakai pseudo-random generation. Jika gagal, kita harus bisa replay.

Engineering policy:

Every PBT failure must print:
- property name
- seed
- shrunk counterexample
- domain classification if available
- minimal reproduction test if worth preserving

Di CI, jangan treat failure PBT sebagai “random flake” sebelum terbukti.

Jika property gagal dengan seed tertentu, simpan:

Property: totalNeverNegative
Seed: 6809460695680213327
Counterexample: Order(...)

Lalu reproduksi lokal dengan seed configuration sesuai jqwik support/project convention.

Repository guideline:

docs/testing/pbt-failures.md
src/test/java/.../RegressionTests.java

14. Classification and Coverage Thinking

Property bisa pass 1000 kali tapi semua input masuk kelas yang sama.

Contoh generator order selalu menghasilkan discount kecil. Bug discount 100% tidak pernah kena.

Gunakan classification mindset:

empty / single / many
small / medium / huge
zero / positive / negative
same currency / different currency
with discount / without discount
with tax / without tax
boundary / non-boundary
valid / invalid
terminal / non-terminal state

Contoh conceptual:

@Property
void totalIsNeverNegative(@ForAll("orders") Order order) {
    Statistics.label("line-count")
        .collect(classifyLineCount(order.lines().size()));

    Statistics.label("discount")
        .collect(order.hasDiscount() ? "with-discount" : "no-discount");

    Money total = calculator.total(order);

    assertThat(total).isGreaterThanOrEqualTo(Money.zero(order.currency()));
}

Classification bukan bukti coverage sempurna. Tapi ia mencegah kita tertipu oleh generator sempit.


15. Edge Cases Should Be Explicitly Injected

Jangan berharap random generator selalu menemukan edge case.

Inject edge cases:

static Arbitrary<BigDecimal> monetaryAmount() {
    return Arbitraries.oneOf(
        Arbitraries.of(
            BigDecimal.ZERO,
            new BigDecimal("0.01"),
            new BigDecimal("999999999.99")
        ),
        Arbitraries.bigDecimals()
            .between(new BigDecimal("0.00"), new BigDecimal("999999999.99"))
            .ofScale(2)
    );
}

Edge case catalog untuk Java backend:

empty string
blank string
unicode string
very long string
zero
one
-1
Integer.MAX_VALUE
Integer.MIN_VALUE
BigDecimal scale mismatch
timezone boundary
DST transition
leap year
empty collection
singleton collection
duplicate elements
same IDs
out-of-order events
repeated commands
missing optional fields
unknown enum value

16. Testing Value Objects with Properties

Value object cocok untuk PBT karena punya invariant kuat.

Contoh Money:

public record Money(String currency, BigDecimal amount) {

    public Money {
        Objects.requireNonNull(currency);
        Objects.requireNonNull(amount);
        if (currency.length() != 3) {
            throw new IllegalArgumentException("currency must be ISO-like 3 letters");
        }
        if (amount.scale() > 2) {
            throw new IllegalArgumentException("amount scale must be <= 2");
        }
    }

    public Money plus(Money other) {
        requireSameCurrency(other);
        return new Money(currency, amount.add(other.amount));
    }

    public Money negate() {
        return new Money(currency, amount.negate());
    }

    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
    }
}

Properties:

class MoneyProperties {

    @Property
    void plusIsAssociativeForSameCurrency(
        @ForAll("usdMoney") Money a,
        @ForAll("usdMoney") Money b,
        @ForAll("usdMoney") Money c
    ) {
        assertThat(a.plus(b).plus(c)).isEqualTo(a.plus(b.plus(c)));
    }

    @Property
    void plusHasZeroIdentity(@ForAll("usdMoney") Money money) {
        assertThat(money.plus(Money.zero("USD"))).isEqualTo(money);
    }

    @Property
    void plusHasInverse(@ForAll("usdMoney") Money money) {
        assertThat(money.plus(money.negate())).isEqualTo(Money.zero("USD"));
    }

    @Provide
    Arbitrary<Money> usdMoney() {
        return Arbitraries.bigDecimals()
            .between(new BigDecimal("-1000000.00"), new BigDecimal("1000000.00"))
            .ofScale(2)
            .map(amount -> new Money("USD", amount));
    }
}

Catatan: associativity pada BigDecimal addition aman untuk scale yang stabil. Untuk floating point double, associativity tidak selalu benar karena precision.

PBT memaksa kita menulis property yang sesuai realitas matematika dan realitas runtime.


17. Testing Parsers and Serializers

Parser/serializer punya property natural:

parse(format(x)) == x

Contoh:

@Property
void moneyFormattingRoundtrip(@ForAll("money") Money money) {
    String formatted = MoneyFormatter.format(money);
    Money parsed = MoneyParser.parse(formatted);

    assertThat(parsed).isEqualTo(money);
}

Tapi hati-hati:

format(parse(s)) == s

biasanya tidak selalu benar karena parser bisa menerima banyak representasi dan formatter melakukan canonicalization.

Lebih benar:

@Property
void parsingThenFormattingProducesCanonicalRepresentation(
    @ForAll("validMoneyStrings") String input
) {
    Money parsed = MoneyParser.parse(input);
    String canonical = MoneyFormatter.format(parsed);

    assertThat(MoneyParser.parse(canonical)).isEqualTo(parsed);
}

Mental model:

parse(format(x)) == x           // round-trip from semantic value
format(parse(s)) == canonical   // normalization from textual representation

18. Metamorphic Testing

Kadang kita tidak punya expected value yang mudah dihitung.

Metamorphic property menggunakan relasi antar input-output.

Contoh search ranking:

Jika query sama dan kita menambahkan dokumen yang tidak match,
hasil top-N untuk dokumen lama tidak boleh berubah.

Contoh validator:

Jika payload valid,
menambahkan whitespace pada field yang trimmed tidak boleh mengubah semantic result.

Contoh pricing:

Jika quantity naik dan unit price positif,
subtotal tidak boleh turun.

Kode:

@Property
void subtotalIsMonotonicWithQuantity(
    @ForAll("positiveUnitPrice") Money unitPrice,
    @ForAll @IntRange(min = 1, max = 1000) int q1,
    @ForAll @IntRange(min = 1, max = 1000) int q2
) {
    int lower = Math.min(q1, q2);
    int higher = Math.max(q1, q2);

    Money subtotalLower = unitPrice.multiply(lower);
    Money subtotalHigher = unitPrice.multiply(higher);

    assertThat(subtotalHigher).isGreaterThanOrEqualTo(subtotalLower);
}

Metamorphic testing sangat kuat untuk:

ranking
recommendation
search
pricing
validation
normalization
optimization algorithms
caching layers

19. Oracle Design Patterns

PBT butuh oracle. Oracle adalah cara memutuskan hasil benar/salah.

19.1 Algebraic Oracle

Gunakan hukum operasi:

normalize(normalize(x)) == normalize(x)

19.2 Reference Implementation Oracle

Bandingkan implementasi cepat dengan implementasi sederhana:

@Property
void optimizedDedupMatchesReference(@ForAll List<String> input) {
    List<String> expected = referenceDedup(input);
    List<String> actual = optimizedDedup(input);

    assertThat(actual).containsExactlyElementsOf(expected);
}

private static List<String> referenceDedup(List<String> input) {
    List<String> result = new ArrayList<>();
    for (String item : input) {
        if (!result.contains(item)) {
            result.add(item);
        }
    }
    return result;
}

19.3 Invariant Oracle

Cek kondisi domain setelah operasi:

@Property
void accountNeverExceedsCreditLimit(@ForAll("validCommandSequence") List<AccountCommand> commands) {
    Account account = Account.open(...);

    for (AccountCommand command : commands) {
        account = account.handle(command);
        assertThat(account.outstandingBalance()).isLessThanOrEqualTo(account.creditLimit());
    }
}

19.4 Metamorphic Oracle

Cek relasi, bukan value final mutlak.

19.5 Differential Oracle

Bandingkan dua sistem:

old implementation vs new implementation
library A vs library B
database query vs in-memory reference
legacy service vs rewritten service

20. Testing Collections and Algorithms

Contoh deduplication:

public static <T> List<T> dedupPreservingOrder(List<T> input) {
    Set<T> seen = new HashSet<>();
    List<T> result = new ArrayList<>();
    for (T item : input) {
        if (seen.add(item)) {
            result.add(item);
        }
    }
    return result;
}

Properties:

@Property
void dedupDoesNotIntroduceNewElements(@ForAll List<String> input) {
    List<String> result = dedupPreservingOrder(input);

    assertThat(input).containsAll(result);
}

@Property
void dedupResultHasNoDuplicates(@ForAll List<String> input) {
    List<String> result = dedupPreservingOrder(input);

    assertThat(new HashSet<>(result)).hasSize(result.size());
}

@Property
void dedupIsIdempotent(@ForAll List<String> input) {
    List<String> once = dedupPreservingOrder(input);
    List<String> twice = dedupPreservingOrder(once);

    assertThat(twice).isEqualTo(once);
}

@Property
void dedupPreservesFirstOccurrenceOrder(@ForAll List<String> input) {
    List<String> result = dedupPreservingOrder(input);

    assertThat(result).isEqualTo(referenceDedup(input));
}

Property set ini jauh lebih kuat daripada satu example test.


21. Testing Business Rules

Misal rule approval:

A case can be auto-approved if:
- risk score <= 30
- no sanction hit
- requested amount <= customer limit
- customer status ACTIVE

Example test biasanya memilih satu happy path.

PBT bisa mengecek rule boundary:

@Property
void autoApprovalRequiresAllEligibilityConditions(
    @ForAll("caseInputs") CaseInput input
) {
    Decision decision = engine.decide(input);

    boolean eligible = input.riskScore() <= 30
        && !input.hasSanctionHit()
        && input.requestedAmount().compareTo(input.customerLimit()) <= 0
        && input.customerStatus() == CustomerStatus.ACTIVE;

    if (eligible) {
        assertThat(decision).isEqualTo(Decision.AUTO_APPROVE);
    } else {
        assertThat(decision).isNotEqualTo(Decision.AUTO_APPROVE);
    }
}

Tapi perhatikan: ini berisiko menduplikasi implementation.

Lebih baik pisahkan rule table sebagai source of truth:

record RuleCase(
    int riskScore,
    boolean sanctionHit,
    Money requestedAmount,
    Money customerLimit,
    CustomerStatus status,
    Decision expected
) {}

Generator menghasilkan kombinasi sekitar boundary:

risk = 29, 30, 31
amount = limit - 1, limit, limit + 1
status = ACTIVE, SUSPENDED, CLOSED
sanction = true, false

Property-nya bukan hanya random; ia sistematis.


22. PBT for Validation Logic

Validator cocok untuk PBT karena punya banyak kombinasi.

Contoh:

@Property
void validRegistrationRequestsAreAccepted(
    @ForAll("validRegistrationRequests") RegistrationRequest request
) {
    ValidationResult result = validator.validate(request);

    assertThat(result.isValid()).isTrue();
}

@Property
void invalidEmailIsRejected(
    @ForAll("validRegistrationRequests") RegistrationRequest valid,
    @ForAll("invalidEmails") String invalidEmail
) {
    RegistrationRequest mutated = valid.withEmail(invalidEmail);

    ValidationResult result = validator.validate(mutated);

    assertThat(result.errors()).contains(error("email", "invalid"));
}

Pattern penting:

generate valid object
mutate one field into invalid state
assert specific error

Ini lebih stabil daripada generate semua invalid random object sekaligus.


23. PBT for Serialization Compatibility

Untuk DTO, schema, event payload, dan API contract:

@Property
void orderEventJsonRoundtrip(@ForAll("orderEvents") OrderEvent event) {
    String json = objectMapper.writeValueAsString(event);
    OrderEvent parsed = objectMapper.readValue(json, OrderEvent.class);

    assertThat(parsed).isEqualTo(event);
}

Compatibility property:

@Property
void v1ReaderCanReadV2EventWhenOnlyOptionalFieldsAreAdded(
    @ForAll("v2EventsWithBackwardCompatibleFields") OrderEventV2 event
) {
    String json = v2Mapper.writeValueAsString(event);

    OrderEventV1 parsedByOldReader = v1Mapper.readValue(json, OrderEventV1.class);

    assertThat(parsedByOldReader.orderId()).isEqualTo(event.orderId());
    assertThat(parsedByOldReader.status()).isEqualTo(event.status());
}

Ini menangkap schema evolution bug yang sering lolos dari example tests.


24. PBT for Repository-Like Code

Repository test sulit karena DB stateful.

PBT tetap bisa dipakai dengan batasan:

@Property(tries = 50)
void savingAndLoadingOrderPreservesSemanticFields(
    @ForAll("orders") Order order
) {
    repository.save(order);

    Order loaded = repository.findById(order.id()).orElseThrow();

    assertThat(loaded).usingRecursiveComparison()
        .ignoringFields("version", "createdAt", "updatedAt")
        .isEqualTo(order);
}

Tapi jangan generate object terlalu besar kalau tiap run menyentuh DB.

Gunakan:

lower tries
transaction rollback per trial
unique IDs
small aggregate size
classification
separate slow suite tag

25. PBT for Security-Adjacent Robustness

PBT bukan pengganti security testing. Tapi berguna untuk robustness.

Contoh normalizer:

@Property
void normalizerNeverThrowsForAnyString(@ForAll String input) {
    assertThatCode(() -> Normalizer.safeNormalize(input))
        .doesNotThrowAnyException();
}

Ini property “no crash”. Tidak cukup untuk correctness, tapi berguna untuk parser/normalizer boundary.

Lebih kuat:

@Property
void normalizedUsernameIsAlwaysWithinAllowedAlphabet(
    @ForAll("userInputStrings") String input
) {
    Optional<Username> result = Username.tryParse(input);

    result.ifPresent(username ->
        assertThat(username.value()).matches("[a-z0-9_]{3,32}")
    );
}

26. When Not to Use PBT

PBT bukan silver bullet.

Kurang cocok untuk:

UI layout exact pixel assertions
one-off integration behavior with expensive external dependency
logic with no clear property
code dominated by side effects and no seam
legacy code with unstable oracle

Tapi sering kali masalahnya bukan “PBT tidak cocok”. Masalahnya desain belum testable.

Kalau kamu tidak bisa menulis property, tanyakan:

Apa invariant sistem ini?
Apa domain input validnya?
Apa output relation yang harus selalu benar?
Apa reference implementation sederhananya?
Apa state modelnya?

Jika semua tidak bisa dijawab, mungkin requirement-nya belum cukup jelas.


27. PBT Integration into Test Strategy

Jangan ubah semua unit test menjadi property test.

Gunakan layering:

example tests     = document specific business examples
property tests    = cover input space and laws
mutation tests    = check oracle strength
contract tests    = verify integration boundaries
model tests       = verify state behavior

Mapping:

AreaExample TestsProperty Tests
Value objectslowhigh
Pure algorithmsmediumhigh
Validatorsmediumhigh
Parsers/serializersmediumhigh
Stateful workflowsmediumhigh, but advanced
External integrationshighselective
UI flowshighlow/selective

28. Suite Design and Performance

Property tests bisa mahal.

Practical policy:

@Group
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Tag("property")
public @interface PropertySuite {}

Run strategy:

local fast: selected property tests
pre-merge: all fast property tests
nightly: expensive property tests + higher tries
release: property + mutation + contract smoke

Maven profile example:

<profile>
    <id>property-tests</id>
    <properties>
        <groups>property</groups>
    </properties>
</profile>

The real goal:

high signal, reproducible failures, bounded runtime

29. Anti-Patterns

29.1 Property Is Just Example in Disguise

@Property
void bad(@ForAll int x) {
    assertThat(service.calculate(2)).isEqualTo(4);
}

29.2 Weak Assertion

assertThat(result).isNotNull();

29.3 Over-Broad Generator

@ForAll String json

Padahal butuh valid JSON.

29.4 Mirrored Oracle

Expected dihitung memakai function yang sama dengan actual.

29.5 Assuming Away the Domain

Assume.that(object.isValid());

untuk hampir semua generated object.

29.6 Non-Deterministic Side Effects

Property melakukan call ke service eksternal, waktu nyata, atau database shared tanpa isolation.

29.7 Too Many Tries Without Better Generator

Menambah tries dari 1000 ke 100000 tidak memperbaiki generator buruk.


30. Practical Recipe: Converting an Example Test to Property Test

Mulai dari example:

@Test
void normalizesSku() {
    assertThat(Sku.normalize(" abc-123 ")).isEqualTo("ABC123");
}

Step 1: cari property.

normalize is idempotent
normalize removes separators
normalize uppercase letters
valid normalized SKU matches allowed alphabet
semantic equivalent raw strings normalize to same value

Step 2: tulis generator.

@Provide
Arbitrary<String> rawSkuLikeStrings() {
    Arbitrary<String> chunks = Arbitraries.strings()
        .withChars('a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3')
        .ofMinLength(1)
        .ofMaxLength(12);

    Arbitrary<String> sep = Arbitraries.of("", "-", "_", " ");

    return Combinators.combine(chunks, sep, chunks)
        .as((a, s, b) -> " " + a + s + b + " ");
}

Step 3: tulis property.

@Property
void normalizationIsIdempotent(@ForAll("rawSkuLikeStrings") String raw) {
    String once = Sku.normalize(raw);
    String twice = Sku.normalize(once);

    assertThat(twice).isEqualTo(once);
}

@Property
void normalizedSkuUsesAllowedAlphabet(@ForAll("rawSkuLikeStrings") String raw) {
    String normalized = Sku.normalize(raw);

    assertThat(normalized).matches("[A-Z0-9]+", "normalized SKU alphabet");
}

Step 4: simpan example sebagai documentation.

@Test
void skuNormalizationExampleForHumanReaders() {
    assertThat(Sku.normalize(" abc-123 ")).isEqualTo("ABC123");
}

31. PBT Review Checklist

Sebelum merge property test, review:

[ ] Property name states a real law/invariant.
[ ] Generator models valid domain, not arbitrary garbage.
[ ] Edge cases are injected intentionally.
[ ] Assertion is stronger than not-null/no-throw unless robustness-only.
[ ] Oracle is independent enough from implementation.
[ ] Failing case can be replayed.
[ ] Runtime is bounded.
[ ] Classification shows meaningful input distribution.
[ ] Side effects are isolated.
[ ] Counterexample can become regression test.

32. How This Connects to the Next Part

Part ini fokus pada basic dan intermediate property-based testing:

properties
arbitraries
shrinking
classification
oracle design
roundtrip
metamorphic properties

Part 012 akan naik level ke domain model:

aggregate generator
command generator
event generator
workflow trace generator
stateful property
model-based oracle
regulatory lifecycle testing
cross-entity invariant testing

Karena real enterprise system jarang hanya fungsi murni. Ia punya lifecycle, state transition, approval, versioning, idempotency, audit, dan concurrency.


33. References


34. Summary

Property-based testing adalah cara berpikir, bukan sekadar library.

Intinya:

Do not only test examples.
Specify laws.
Generate meaningful inputs.
Search for counterexamples.
Shrink failures.
Convert failures into regression tests.

Skill top-level-nya bukan menulis @Property.

Skill top-level-nya adalah mampu menjawab:

Apa yang harus selalu benar untuk semua input valid?
Apa generator domain yang mewakili realitas?
Apa oracle yang tidak membohongi kita?
Apa counterexample minimal yang menjelaskan bug?

Kalau kamu bisa menjawab itu, property-based testing menjadi alat desain, bukan hanya alat testing.

Lesson Recap

You just completed lesson 11 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.