Property-Based Testing in JUnit 6 with jqwik (Complete Guide)

Example-based tests verify that your code behaves correctly for the specific inputs you think of. Property-based testing takes a different approach: you define the properties your code must satisfy — invariants that should hold for all valid inputs — and the framework generates hundreds or thousands of inputs automatically to try to falsify them. This finds edge cases you would never think to write yourself. jqwik is the leading property-based testing library for the JUnit Platform, and this guide covers everything you need to use it effectively.

Example-Based vs Property-Based Testing

Example-Based (@ParameterizedTest)Property-Based (jqwik)
InputsYou provide specific valuesFramework generates random values
CoverageOnly what you thought to testWide random sweep + edge cases
ReproducibilityAlways the same inputsSame seed = same inputs (stored on failure)
Finding bugsFinds bugs in known scenariosFinds bugs in unexpected corner cases
Best forKnown rules, specific business logicAlgorithms, parsers, data transformations

Setup: Adding jqwik

<dependency>
    <groupId>net.jqwik</groupId>
    <artifactId>jqwik</artifactId>
    <version>1.9.0</version>
    <scope>test</scope>
</dependency>

<!-- jqwik runs on the JUnit Platform — ensure Surefire is 3.x -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.2.5</version>
</plugin>

Your First Property: The Basics

Replace @Test with @Property and add annotated parameters. jqwik generates values for them automatically:

import net.jqwik.api.*;
import static org.junit.jupiter.api.Assertions.*;

class StringUtilsPropertyTest {

    // @Property runs this test ~1000 times with different random String values
    // @ForAll means jqwik generates arbitrary values of that type
    @Property
    void reversingAStringTwiceYieldsOriginal(@ForAll String anyString) {
        // Property: reverse(reverse(s)) == s for ANY string
        String reversed = StringUtils.reverse(anyString);
        String doubleReversed = StringUtils.reverse(reversed);

        assertEquals(anyString, doubleReversed,
            "Reversing any string twice must return the original string");
    }

    @Property
    void reversedStringHasSameLength(@ForAll String anyString) {
        // Property: reversing a string never changes its length
        assertEquals(anyString.length(), StringUtils.reverse(anyString).length(),
            "Reversed string must have same length as original");
    }
}
Output:
tries = 1000                   | # of generated test cases
checks = 1000                  | # that satisfied constraints
seed = -2947183742894765392    | reproducibility seed

✔ reversingAStringTwiceYieldsOriginal (1000 tries)
✔ reversedStringHasSameLength (1000 tries)

Constraining Generated Values with Arbitraries

Use constraint annotations or custom Arbitrary providers to narrow the generated input space to values that make sense for your domain:

import net.jqwik.api.constraints.*;

class PricingPropertyTest {

    // @Positive: generates only positive doubles
    @Property
    void pricingEngineNeverProducesNegativePrice(
            @ForAll @Positive double basePrice,
            @ForAll @DoubleRange(min = 0.0, max = 1.0) double discountRate) {

        double finalPrice = pricingEngine.applyDiscount(basePrice, discountRate);

        // Property: applying any valid discount never produces a negative price
        assertTrue(finalPrice >= 0.0,
            "Final price must never be negative: basePrice=" + basePrice
            + ", discountRate=" + discountRate + ", finalPrice=" + finalPrice);
    }

    // @IntRange constrains integer generation
    @Property
    void processingAnyValidQuantityNeverThrows(
            @ForAll @IntRange(min = 1, max = 9999) int quantity) {
        // Property: valid quantities should never cause an exception
        assertDoesNotThrow(
            () -> orderService.createOrder("[email protected]", quantity),
            "Any quantity from 1 to 9999 must be processable"
        );
    }

    // @StringLength constrains generated string lengths
    @Property
    void productNamesUnder255CharsAreAlwaysValid(
            @ForAll @StringLength(min = 1, max = 255) String productName) {
        Product product = new Product(productName, 9.99);
        assertTrue(product.isValid(),
            "Product names up to 255 chars must always be valid");
    }
}

Custom Arbitraries: Domain-Specific Generation

import net.jqwik.api.*;

class OrderPropertyTest {

    // @Provide: factory method for custom Arbitrary
    @Provide
    Arbitrary<Order> validOrders() {
        // Combine multiple arbitraries to generate complex valid Order objects
        Arbitrary<String> emails  = Arbitraries.strings()
            .withCharRange('a', 'z').ofLength(5)
            .map(local -> local + "@test.com");

        Arbitrary<Double> amounts = Arbitraries.doubles()
            .between(0.01, 10_000.00);

        // Combine: generates a valid Order for every combination
        return Combinators.combine(emails, amounts)
            .as((email, amount) -> new Order(null, email, amount, OrderStatus.PENDING));
    }

    // @ForAll("validOrders") references the @Provide method above
    @Property
    void totalTaxForAnyValidOrderIsAlwaysPositive(
            @ForAll("validOrders") Order order) {
        double tax = taxCalculator.calculate(order);

        // Property: tax on any valid order is always positive
        assertTrue(tax > 0,
            "Tax must be positive for any valid order with amount > 0, was: " + tax);
    }

    @Property
    void serialisingAndDeserialisingAnyOrderPreservesAllFields(
            @ForAll("validOrders") Order order) throws Exception {
        // Property: serialise → deserialise is an identity operation
        String json         = objectMapper.writeValueAsString(order);
        Order deserialized  = objectMapper.readValue(json, Order.class);

        assertEquals(order.getCustomerEmail(), deserialized.getCustomerEmail());
        assertEquals(order.getTotalAmount(),   deserialized.getTotalAmount(), 0.001);
        assertEquals(order.getStatus(),        deserialized.getStatus());
    }
}

Reproducing Failures: Seeds

When jqwik finds a failing case, it prints the seed and the exact failing values. You can reproduce it precisely:

# jqwik failure output
jqwik: Property [reversingAStringTwiceYieldsOriginal] failed:
  seed = 3847291827364
  |  # of generated test cases: 42
  |  Falsified after 42 tries
  |  Sample
  |    anyString = "😀" (a Unicode emoji — the reversal algorithm failed on multi-byte chars!)

# Reproduce with exact seed:
@Property(seed = "3847291827364")
void reversingAStringTwiceYieldsOriginal(@ForAll String anyString) { ... }

When to Use Property-Based Testing

  • String/data parsers — test that parsing any valid input does not throw
  • Sorting and ordering algorithms — verify sorted output is always ordered, same length, same elements
  • Serialisation/deserialisation — verify roundtrip is always lossless
  • Mathematical operations — verify commutativity, associativity, identity properties
  • Boundary value logic — pricing, discounts, tax calculations with any valid input
  • ⚠️ Use sparingly for database or I/O tests — 1000 database inserts per test run is too slow

Frequently Asked Questions (FAQs)

Q1: Does property-based testing replace example-based testing?

No — they complement each other. Example-based tests verify specific known business rules ("a 10% discount on £100 gives £90"). Property-based tests verify invariants that should hold for all inputs ("any valid discount always produces a non-negative price"). Use both: example tests for business rules, property tests for algorithmic correctness and robustness under arbitrary input.

Q2: How many tries does jqwik run per property?

By default, jqwik runs 1000 tries per @Property. You can change this globally in jqwik.properties or per-property with @Property(tries = 500). For complex domain objects or slow operations, reduce tries to balance coverage with speed. For critical algorithms, increase tries to 5000 or more for higher confidence.

Q3: What is shrinking and why does it matter?

When jqwik finds a failing input, it automatically shrinks it — tries progressively simpler versions of the failing input to find the minimal case that still fails. For example, if a 500-character string fails, jqwik might shrink it to "😀" (a single emoji). This minimal failing case is far easier to debug than the original random value. Shrinking is one of the most powerful features of property-based testing.

Q4: Can I use jqwik alongside regular JUnit 6 @Test methods in the same class?

Yes. jqwik runs on the JUnit Platform, so @Property and @Test methods coexist in the same test class without conflict. Both appear in the same test report. The only difference is that @Property tests show try count and seed in their output while @Test methods show pass/fail.

Q5: How does jqwik handle null values in generated inputs?

By default, jqwik does not generate null values for @ForAll parameters — it generates non-null values of the specified type. To include null, add @ForAll @WithNull String maybeNullString. The @WithNull annotation tells jqwik to occasionally include null in the generated values, which is useful for testing null-handling code paths.

See Also

Conclusion

Property-based testing with jqwik finds entire classes of bugs that example-based tests miss. Define the invariants your code must satisfy for all inputs, let jqwik generate thousands of test cases, and watch it find the unicode emoji, the null in the middle of a list, or the zero-length string your implementation never handled. It is not a replacement for example-based tests — it is a powerful complement that makes your test suite dramatically more comprehensive.

Next: Mutation Testing with PIT and JUnit 6 — verify that your tests actually catch bugs by introducing artificial mutations and measuring how many your suite detects.

Leave a Reply

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