I run into enough enterprise projects with reasonable unit-test counts and coverage requirements enforced in CI. The numbers often look super healthy on dashboards and reports: thousands of tests, a coverage threshold, and a rule that blocks a merge when coverage drops. Controls like this can be convinient. They force new code through the suite and make a sudden loss of coverage visible during review. And they give a lot of peace of mind to management.
When I read the actual tests, the story might quickly look slightly different.
A test count tells us how many tests ran. Line and branch coverage tell us which parts of the code the suite executed. Neither metric says anything about whether the assertions describe the correct business rule, whether the inputs reach risky combinations, or whether 50 tests repeat the same assumption with different values. A line can be covered while its result is barely checked.
That is why I am very skeptical when looking at results like this:
Tests run: 50, Failures: 0, Errors: 0, Skipped: 0All 50 tests ran. All 50 passed. The suite covers small prices, large prices, several quantities, and discounts from zero through 50%. It executes the pricing calculation and checks an expected result each time. A coverage report has no obvious reason to complain.
The calculation can still be wrong. Maybe it rounds the discounted unit price before multiplying by the quantity, although the business rule says to round the complete line total. Every selected input happens to produce the same result under both rounding orders. The test report is accurate but has a hole in it. It simply does not answer the small question than I need it to answer.
But how do we prevent this kind of subtle failure?
I created the little example to try to explain the answer to this question. It obviously is an artificial, compact version of an enterprise application with a seemingly large test suite. Here, 50 CSV rows should represent thousands of test methods. Both can execute a lot of code while exploring a narrow set of ideas.
I also start with conventional unit tests because they usually are what we find when we inspect these projects. Small concrete examples, readable expectations, and stable regression cases. Their reach depends on which inputs were selected and how the expected outputs were derived. What is often overlooked is that this is only a selection and it will be biased. It may come from a developer, a test-generation tool, or even a coding agent.
Agentic test generation makes this selection not only much faster but also more convenient. A coding agent can inspect a class, produce dozens of tests, run the suite, and revise its output from the results. It can also read the same faulty implementation it is supposed to test. If the agent derives each expected value from that code, it may copy the defect into the test. In this example, an agent could calculate every expectation with the same early rounding and produce another 50 green tests. The suite gets larger and coverage rises, but the wrong calculation now has more tests that do not effectively test it after all. A 2026 replication study of LLM-generated Java tests found that suite size had only a weak relationship with real-bug detection and that coverage became an unreliable indicator when the code shown to the model already contained a defect. Well. Thanks. As Senior people in the field, this was kind of expected.
And honestly, it should be no surprise that we already have everything at hand to prevent this. Property-based testing gives us a completely different angle to testing: We describe a relationship that must hold across valid input domains, then let the test engine try many values against that statement.
jqwik brings property-based testing to Java and runs on the JUnit Platform. A jqwik property receives values from generators and checks the same rule for every generated combination. jqwik tries to falsify the property, which means finding one input that makes the statement fail. When it succeeds, it shrinks that input toward a smaller failing case. The original failure proves a defect exists; the smaller case usually helps developers to figure out the exact reason it fails.
This is especially well suited to substitute agent-written code. The property gives the test suite a target outside the individual examples the agent selected. While the agent may produce a plausible implementation that passes every named case, this approach forces it to also preserve the invariant across inputs it never listed. That is how a six-cent order can expose a rounding defect that 50 generated examples missed.
And yet: We still will have to review even property based tests. An agent can write a weak property, generate the wrong domain, or calculate the expected result by calling the code under test. So the ultimate measure to prevent this is to check the invariant, the independent formula, and the generator limits as carefully as the real production ranges. Recent agentic property-based testing research follows a similar loop: an agent studies a codebase, proposes properties, generates and runs tests, and triages the failures. The linked research targets Python and Hypothesis. For todays example we naturally use Java and jqwik, but the testing idea is the same behind both.
While I keep this example small, this style of testing becomes relevant when combinations grow faster than named examples can follow and I can state the rule separately from the production implementation. Pricing arithmetic is almost a perfect domain for this. Totals should never be negative, a larger discount should never increase a total, and line-level rounding should agree with an independent formula. Parsers, serializers, collections, and date calculations often have the same shape: many valid inputs, a few precise relationships, and defects hiding in combinations nobody selected by hand.
Time to put that idea into a Quarkus REST service. The first tests are 50 normal JUnit examples that pass against a calculator that rounds too early. Then we’ll add three properties that also pass and one stronger property that fails. We’ll use the failure to correct the calculation and keep jqwik’s smallest example as a conventional regression test. Let’s go:
What You Will Build
We keep the service small: one POST /prices/calculate endpoint for one line item. A request contains a unit price, a quantity, and an integer discount percentage. The response separates the subtotal, discount amount, and final total:
POST /prices/calculate
v
PriceRequest
v
PriceCalculator -- line-level rounding, HALF_UP
v
PriceBreakdownThe accepted domain is super small:
Unit price from
0.00through1000000.00, with at most two decimal placesQuantity from
1through10000Discount percentage from
0through50Currency fixed to EUR for this example
Those limits help with two things: The HTTP layer rejects values the business rule does not support, and the property generators search the same domain.
What You Need
I use latest Quarkus 3.39.1 (as usual), Java 25 (because we want to be up to date), JUnit 6 through the Quarkus test extension, and jqwik 1.10.1.
Java 25 or newer
The Quarkus CLI
curlAbout ☕️
You need basic JUnit knowledge, but no previous property-based testing experience. jqwik runs as another engine on the JUnit Platform, so both test styles can live in the same Maven build.
I use records for the request and response data, and text blocks for JSON request bodies in the REST tests. So, all pretty simple things you get to deal with.
Create the Quarkus Application
Create an empty application with Quarkus REST and Jackson and follow along or start from my example project in my Github repository:
quarkus create app -P io.quarkus.platform:quarkus-bom:3.39.1 \
--maven \
--java=25 \
--extension=rest-jackson \
--no-code \
com.acme:quarkus-jqwik-pricing
cd quarkus-jqwik-pricingI use rest-jackson for typed JSON requests and responses. The price calculation stays in plain Java, so the properties do not boot Quarkus 50,000 times. Quarkus starts for a small set of HTTP tests; the large generated search stays around the business rule.
jqwik is not a Quarkus platform extension, so we have to add its test dependency directly to pom.xml:
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.10.1</version>
<scope>test</scope>
</dependency>The jqwik Maven setup uses standard Surefire support for the JUnit Platform. The generated Quarkus project already has Surefire and the Jupiter engine configured.
Define the HTTP Contract
Create src/main/java/com/acme/pricing/PriceRequest.java:
package com.acme.pricing;
import java.math.BigDecimal;
public record PriceRequest(BigDecimal unitPrice, int quantity, int discountPercent) {}Create src/main/java/com/acme/pricing/PriceBreakdown.java:
package com.acme.pricing;
import java.math.BigDecimal;
public record PriceBreakdown(
BigDecimal subtotal, BigDecimal discountAmount, BigDecimal total, String currency) {}These records carry the JSON data. We keep validation and normalization in PriceCalculator because every caller must follow the same domain rules. The property suite can then call the calculator directly without bypassing validation.
Add the Buggy Calculator
Create src/main/java/com/acme/pricing/PriceCalculator.java:
package com.acme.pricing;
import java.math.BigDecimal;
import java.math.RoundingMode;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PriceCalculator {
private static final BigDecimal MAX_UNIT_PRICE = new BigDecimal("1000000.00");
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private static final int MAX_QUANTITY = 10_000;
private static final int MAX_DISCOUNT_PERCENT = 50;
public PriceBreakdown calculate(PriceRequest request) {
if (request == null) {
throw new IllegalArgumentException("request body must not be null");
}
BigDecimal unitPrice = normalizeUnitPrice(request.unitPrice());
validateQuantity(request.quantity());
validateDiscount(request.discountPercent());
BigDecimal quantity = BigDecimal.valueOf(request.quantity());
BigDecimal subtotal = unitPrice.multiply(quantity);
BigDecimal remainingRate = BigDecimal.valueOf(100L - request.discountPercent())
.divide(ONE_HUNDRED);
BigDecimal discountedUnitPrice = unitPrice
.multiply(remainingRate)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal total = discountedUnitPrice.multiply(quantity).setScale(2, RoundingMode.HALF_UP);
BigDecimal discountAmount = subtotal.subtract(total).setScale(2, RoundingMode.HALF_UP);
return new PriceBreakdown(subtotal, discountAmount, total, "EUR");
}
private BigDecimal normalizeUnitPrice(BigDecimal unitPrice) {
if (unitPrice == null) {
throw new IllegalArgumentException("unitPrice must not be null");
}
try {
unitPrice = unitPrice.setScale(2, RoundingMode.UNNECESSARY);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("unitPrice must have at most two decimal places", exception);
}
if (unitPrice.signum() < 0 || unitPrice.compareTo(MAX_UNIT_PRICE) > 0) {
throw new IllegalArgumentException("unitPrice must be between 0.00 and 1000000.00");
}
return unitPrice;
}
private void validateQuantity(int quantity) {
if (quantity < 1 || quantity > MAX_QUANTITY) {
throw new IllegalArgumentException("quantity must be between 1 and 10000");
}
}
private void validateDiscount(int discountPercent) {
if (discountPercent < 0 || discountPercent > MAX_DISCOUNT_PERCENT) {
throw new IllegalArgumentException("discountPercent must be between 0 and 50");
}
}
}I keep the validation strict. setScale(2, UNNECESSARY) accepts 19.990 because reducing the scale does not change its value, but it rejects 19.999. I also limit price and quantity before multiplication.
The defect sits in the four lines that calculate discountedUnitPrice. I round the discounted unit price to cents and then multiply it by the quantity. The business rule says to multiply first, apply the discount to the subtotal, and round once at the line level.
Both orders return the same answer when the discounted unit price lands exactly on a cent. Hand-written examples often use whole euro prices and discounts in five-point steps, so it is easy to choose 50 cases that hide the defect.
Expose the Calculator over REST
Create src/main/java/com/acme/pricing/PriceResource.java:
package com.acme.pricing;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/prices")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PriceResource {
private final PriceCalculator priceCalculator;
public PriceResource(PriceCalculator priceCalculator) {
this.priceCalculator = priceCalculator;
}
@POST
@Path("/calculate")
public PriceBreakdown calculate(PriceRequest request) {
return priceCalculator.calculate(request);
}
}It is a light endpoint. It uses constructor injection and performs no calculation: JSON comes in, the calculator applies the business rule, and a typed response goes out.
Map domain input errors to an explicit JSON response. Create src/main/java/com/acme/pricing/ErrorResponse.java:
package com.acme.pricing;
public record ErrorResponse(String code, String message) {}Create src/main/java/com/acme/pricing/PricingExceptionMappers.java:
package com.acme.pricing;
import jakarta.ws.rs.core.Response;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
class PricingExceptionMappers {
@ServerExceptionMapper
public RestResponse<ErrorResponse> mapException(IllegalArgumentException exception) {
ErrorResponse error = new ErrorResponse("invalid_price_request", exception.getMessage());
return RestResponse.status(Response.Status.BAD_REQUEST, error);
}
}I map a rejected domain value to HTTP 400. The typed RestResponse<ErrorResponse> also lets Quarkus identify the serialized response type during the build.
Write 50 Examples
Now create src/test/java/com/acme/pricing/PriceCalculatorExampleTest.java. I chose values across the full discount range, several order sizes, a zero-discount identity, the maximum unit price, and the 49% and 50% limits. None of them hits a rounding combination that exposes the defect:
package com.acme.pricing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PriceCalculatorExampleTest {
private final PriceCalculator calculator = new PriceCalculator();
@ParameterizedTest(name = "{index}: {0} x {1} with {2}% = {3}")
@CsvSource({
"0.01, 3, 0, 0.03",
"0.99, 7, 0, 6.93",
"12.49, 2, 0, 24.98",
"199.95, 10, 0, 1999.50",
"1000000.00, 1, 0, 1000000.00",
"1.00, 1, 5, 0.95",
"2.40, 3, 5, 6.84",
"19.80, 7, 5, 131.67",
"129.00, 50, 5, 6127.50",
"0.10, 5, 10, 0.45",
"2.50, 2, 10, 4.50",
"19.90, 3, 10, 53.73",
"249.00, 12, 10, 2689.20",
"0.20, 2, 15, 0.34",
"5.00, 7, 15, 29.75",
"19.80, 5, 15, 84.15",
"120.00, 25, 15, 2550.00",
"0.05, 10, 20, 0.40",
"1.25, 4, 20, 4.00",
"9.95, 3, 20, 23.88",
"250.00, 99, 20, 19800.00",
"0.04, 2, 25, 0.06",
"1.24, 5, 25, 4.65",
"49.96, 4, 25, 149.88",
"1000.00, 10, 25, 7500.00",
"0.10, 7, 30, 0.49",
"1.50, 3, 30, 3.15",
"19.90, 12, 30, 167.16",
"99.00, 50, 30, 3465.00",
"0.20, 4, 35, 0.52",
"2.00, 5, 35, 6.50",
"24.80, 3, 35, 48.36",
"200.00, 20, 35, 2600.00",
"0.05, 5, 40, 0.15",
"1.25, 2, 40, 1.50",
"9.95, 7, 40, 41.79",
"129.90, 10, 40, 779.40",
"0.20, 3, 45, 0.33",
"2.00, 4, 45, 4.40",
"19.80, 12, 45, 130.68",
"200.00, 25, 45, 2750.00",
"1.00, 2, 49, 1.02",
"25.00, 4, 49, 51.00",
"99.00, 7, 49, 353.43",
"500.00, 50, 49, 12750.00",
"0.02, 3, 50, 0.03",
"1.00, 5, 50, 2.50",
"19.98, 2, 50, 19.98",
"129.90, 10, 50, 649.50",
"1000.00, 99, 50, 49500.00"
})
void calculatesExpectedTotal(
String unitPrice, int quantity, int discountPercent, String expectedTotal) {
PriceRequest request = new PriceRequest(new BigDecimal(unitPrice), quantity, discountPercent);
PriceBreakdown result = calculator.calculate(request);
assertEquals(new BigDecimal(expectedTotal), result.total());
}
}Run only this class:
./mvnw test -Dtest=PriceCalculatorExampleTestSurefire reports 50 successful invocations:
Tests run: 50, Failures: 0, Errors: 0, Skipped: 0These are credible examples. They cover normal prices and the stated limits. Their weakness is selection: I knew every input before the test ran. The suite can confirm only the cases I already considered. Or the agent. Or another developer.
Stop Testing Only Answers
Let’s now change the question. An example asks whether one known calculation returns one known answer. A property asks which relationship must hold across the valid input domain.
A good start would be with three relationships that do not need an independent price source:
A valid request never produces a negative total
A larger discount never increases the total for the same price and quantity
A zero discount equals the unit price multiplied by the quantity
The fourth property states the exact business rule. It calculates the complete line subtotal, applies the percentage, rounds once, and compares that independent result with the calculator.
Create src/test/java/com/acme/pricing/PriceCalculatorPropertyTest.java:
package com.acme.pricing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.math.RoundingMode;
import net.jqwik.api.Arbitraries;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.ForAll;
import net.jqwik.api.Property;
import net.jqwik.api.Provide;
class PriceCalculatorPropertyTest {
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private final PriceCalculator calculator = new PriceCalculator();
@Property(tries = 50)
void totalIsNeverNegative(
@ForAll("unitPrices") BigDecimal unitPrice,
@ForAll("quantities") int quantity,
@ForAll("discounts") int discountPercent) {
PriceBreakdown result = calculator.calculate(new PriceRequest(unitPrice, quantity, discountPercent));
assertTrue(result.total().signum() >= 0);
}
@Property(tries = 50)
void largerDiscountNeverIncreasesTotal(
@ForAll("unitPrices") BigDecimal unitPrice,
@ForAll("quantities") int quantity,
@ForAll("discounts") int firstDiscount,
@ForAll("discounts") int secondDiscount) {
int lowerDiscount = Math.min(firstDiscount, secondDiscount);
int higherDiscount = Math.max(firstDiscount, secondDiscount);
BigDecimal lowerDiscountTotal = calculateTotal(unitPrice, quantity, lowerDiscount);
BigDecimal higherDiscountTotal = calculateTotal(unitPrice, quantity, higherDiscount);
assertTrue(higherDiscountTotal.compareTo(lowerDiscountTotal) <= 0);
}
@Property(tries = 50)
void zeroDiscountEqualsUnitPriceTimesQuantity(
@ForAll("unitPrices") BigDecimal unitPrice, @ForAll("quantities") int quantity) {
BigDecimal expected = unitPrice.multiply(BigDecimal.valueOf(quantity)).setScale(2);
assertEquals(expected, calculateTotal(unitPrice, quantity, 0));
}
@Property(tries = 50, seed = "20260927")
void totalMatchesLineLevelRounding(
@ForAll("unitPrices") BigDecimal unitPrice,
@ForAll("quantities") int quantity,
@ForAll("discounts") int discountPercent) {
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
BigDecimal remainingRate = BigDecimal.valueOf(100L - discountPercent).divide(ONE_HUNDRED);
BigDecimal expected = subtotal.multiply(remainingRate).setScale(2, RoundingMode.HALF_UP);
assertEquals(
expected,
calculateTotal(unitPrice, quantity, discountPercent),
() -> "unitPrice=" + unitPrice + ", quantity=" + quantity + ", discountPercent="
+ discountPercent);
}
@Provide
Arbitrary<BigDecimal> unitPrices() {
return Arbitraries.longs()
.between(0, 100_000_000)
.map(cents -> BigDecimal.valueOf(cents, 2));
}
@Provide
Arbitrary<Integer> quantities() {
return Arbitraries.integers().between(1, 10_000);
}
@Provide
Arbitrary<Integer> discounts() {
return Arbitraries.integers().between(0, 50);
}
private BigDecimal calculateTotal(BigDecimal unitPrice, int quantity, int discountPercent) {
return calculator.calculate(new PriceRequest(unitPrice, quantity, discountPercent)).total();
}
}I give the providers the same limits as the production code in this test. unitPrices() generates integer cents and maps them to a scale-two BigDecimal; it never creates a value such as 3.14159 that the API would reject. The quantity and discount providers generate valid values directly, so jqwik does not spend attempts on assumptions and discarded inputs.
I pin the seed so you can reproduce the same failure. After the failure becomes a regression example, I’ll remove the seed and let later builds search different sequences. If you want to inspect the original generated input before shrinking, temporarily add shrinking = net.jqwik.api.ShrinkingMode.OFF to the final @Property, run it once, and remove the attribute again.
Run the properties:
./mvnw test -Dtest=PriceCalculatorPropertyTestThe first three properties pass. They describe real behavior, but they do not catch this defect. The line-level rounding property fails. With shrinking disabled, seed 20260927 first produces this combination:
unitPrice=161673.40, quantity=875, discountPercent=9
expected: <128732444.75> but was: <128732441.25>The input is valid, but a nine-digit subtotal is awkward to inspect. With normal shrinking enabled, jqwik reduces the same failure to this:
unitPrice=0.01, quantity=6, discountPercent=9
expected: <0.05> but was: <0.06>Now I can see the mistake without a calculator on my desk. Rounding each discounted cent gives 0.01, which becomes 0.06 after multiplication. Calculating the six-cent subtotal first gives 0.054 after the 9% discount, which rounds to 0.05.
The generated input exposed the defect. The shrunk input explained it.
Round at the Line Level
Replace PriceCalculator.java with the corrected implementation:
package com.acme.pricing;
import java.math.BigDecimal;
import java.math.RoundingMode;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PriceCalculator {
private static final BigDecimal MAX_UNIT_PRICE = new BigDecimal("1000000.00");
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private static final int MAX_QUANTITY = 10_000;
private static final int MAX_DISCOUNT_PERCENT = 50;
public PriceBreakdown calculate(PriceRequest request) {
if (request == null) {
throw new IllegalArgumentException("request body must not be null");
}
BigDecimal unitPrice = normalizeUnitPrice(request.unitPrice());
validateQuantity(request.quantity());
validateDiscount(request.discountPercent());
BigDecimal quantity = BigDecimal.valueOf(request.quantity());
BigDecimal subtotal = unitPrice.multiply(quantity);
BigDecimal remainingRate = BigDecimal.valueOf(100L - request.discountPercent())
.divide(ONE_HUNDRED);
BigDecimal total = subtotal.multiply(remainingRate).setScale(2, RoundingMode.HALF_UP);
BigDecimal discountAmount = subtotal.subtract(total).setScale(2, RoundingMode.HALF_UP);
return new PriceBreakdown(subtotal, discountAmount, total, "EUR");
}
private BigDecimal normalizeUnitPrice(BigDecimal unitPrice) {
if (unitPrice == null) {
throw new IllegalArgumentException("unitPrice must not be null");
}
try {
unitPrice = unitPrice.setScale(2, RoundingMode.UNNECESSARY);
} catch (ArithmeticException exception) {
throw new IllegalArgumentException("unitPrice must have at most two decimal places", exception);
}
if (unitPrice.signum() < 0 || unitPrice.compareTo(MAX_UNIT_PRICE) > 0) {
throw new IllegalArgumentException("unitPrice must be between 0.00 and 1000000.00");
}
return unitPrice;
}
private void validateQuantity(int quantity) {
if (quantity < 1 || quantity > MAX_QUANTITY) {
throw new IllegalArgumentException("quantity must be between 1 and 10000");
}
}
private void validateDiscount(int discountPercent) {
if (discountPercent < 0 || discountPercent > MAX_DISCOUNT_PERCENT) {
throw new IllegalArgumentException("discountPercent must be between 0 and 50");
}
}
}I remove the rounded unit-price intermediate value. The corrected code calculates the exact subtotal, multiplies it by the remaining rate, and rounds the line total once. I derive the discount amount from subtotal minus total, so all three response values still reconcile to the cent.
Run the property class again. All four properties now pass.
Keep Test 51
LEt’s not throw away the minimized example after the property turns green. The property can still discover nearby failures as the implementation changes or grows older. A named example records this exact defect for the next engineer reading the suite.
Create src/test/java/com/acme/pricing/RoundingRegressionTest.java:
package com.acme.pricing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class RoundingRegressionTest {
private final PriceCalculator calculator = new PriceCalculator();
@Test
void keepsTheDiscoveredRoundingCase() {
PriceRequest request = new PriceRequest(new BigDecimal("0.01"), 6, 9);
PriceBreakdown result = calculator.calculate(request);
assertEquals(new BigDecimal("0.05"), result.total());
}
}Now remove the fixed seed from totalMatchesLineLevelRounding, and increase that property to @Property(tries = 50_000). The final property still runs as a plain unit test and completes quickly because it does not start Quarkus or call a database.
Run the conventional examples and the regression test together:
./mvnw test -Dtest=PriceCalculatorExampleTest,RoundingRegressionTestThis looks promissing and much better now.
Tests run: 51, Failures: 0, Errors: 0, Skipped: 0I think it is important to keep both forms because they answer different questions. The property continues to search. The named example explains this exact defect during review.
Verify the Quarkus API
And yes, we can absolutely also validate the full application even if we already hit the point in the article. Just test what we build with running the application in dev mode and testing the curls below.
./mvnw quarkus:devCall the endpoint with a combination that exposes the old rounding order:
curl --request POST http://localhost:8080/prices/calculate \
--header 'Content-Type: application/json' \
--data '{"unitPrice":0.07,"quantity":3,"discountPercent":10}'Check the maximum discount as well. 49 and 50 are valid; 51 is not:
curl --include --request POST http://localhost:8080/prices/calculate \
--header 'Content-Type: application/json' \
--data '{"unitPrice":19.99,"quantity":2,"discountPercent":51}'And if you want you can also capture this in a @QuarkusTest. src/test/java/com/acme/pricing/PriceResourceTest.java:
If you want to quickly run the integration tests, just run:
./mvnw testThe final suite has 50 original examples, one regression example, four properties, and two HTTP tests.
Keep the Generators Honest
Even with property based testing, you can still be under the wrong confidence when the generators describe the wrong domain. Keep the generator limits aligned with production validation. If the service starts accepting fractional percentages or four-decimal unit prices, you will have to change both the service and the providers in the same pull request.
This example was super simple but showed something I strive for in real applications: Keep models explicit. This calculator accepts two-decimal EUR amounts and uses HALF_UP at the line level. A multi-currency service cannot assume every currency has two minor digits, and tax rules may require a different rounding point. jqwik will explore the rule you encode, even when when you encode the wrong rule.
Another piece I do is treating a random seed as a debugging handle, and not as permanent coverage. jqwik reports the seed for a failing run and can replay that run. Once the minimized input has become a named regression test, I remove the fixed seed so normal builds continue exploring other sequences.
Generally I think that property tests are a debugging mechanism and not a full replacement for unit or even integration tests. They help me search an input domain for statements the code violates. The process to find statements I forgot to write is still up to me.
Conclusion
I started with 50 passing examples and a broken pricing calculation. Once I stated the line-level rounding rule, jqwik found the missing combination. Shrinking reduced the failure to six units at one cent each, and test 51 preserved that explanation after the fix.
The 50 examples confirmed what I had already imagined. Case 51 showed me what I had missed. Happy testing. On to the next tests.


