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) | |
|---|---|---|
| Inputs | You provide specific values | Framework generates random values |
| Coverage | Only what you thought to test | Wide random sweep + edge cases |
| Reproducibility | Always the same inputs | Same seed = same inputs (stored on failure) |
| Finding bugs | Finds bugs in known scenarios | Finds bugs in unexpected corner cases |
| Best for | Known rules, specific business logic | Algorithms, 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
- Parameterized Tests in JUnit 6: All Sources Explained with Examples
- Mutation Testing with PIT and JUnit 6 (Improve Test Quality)
- Dynamic Tests in JUnit 6 using @TestFactory
- Writing Maintainable Tests in JUnit 6 (Clean Code Principles)
- JUnit 6 Tutorial: Complete Series Index
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.