Property-Based Testing with AI: A Practical FsCheck Guide
Example-based tests only check the cases you thought of. Property-based testing checks the invariants that must always hold — and AI is unusually good at generating them. A practical guide with FsCheck for .NET.
A traditional unit test asserts one input produces one expected output: `Add(2, 3)` should equal `5`. A property-based test asserts something that must be true for *every* valid input: `Add(a, b)` should always equal `Add(b, a)`. The test framework then generates hundreds of random inputs — including edge cases you'd never have thought to write by hand — and tries to find one that breaks the property.
Why this matters more with AI-generated code
AI-assisted development increases the volume of code you're reviewing and shipping. Example-based tests only prove the specific cases someone (human or AI) thought to write — they say nothing about the input space nobody considered. Property-based testing is a structural defense: it doesn't rely on anyone's imagination for edge cases, it searches for counterexamples automatically.
The pattern: finding properties, not examples
The hard part of property-based testing isn't the tooling — it's identifying what properties actually hold for your function. This is where AI is a genuinely good collaborator: describe the function's contract, and ask for the invariants, not just test cases.
| Property pattern | What it checks | Example |
|---|---|---|
| Round-trip | Serialize then deserialize returns the original | `Deserialize(Serialize(x)) == x` |
| Idempotence | Applying the operation twice equals applying it once | `Normalize(Normalize(s)) == Normalize(s)` |
| Invariant preservation | A domain invariant holds after every valid operation | Account balance never goes negative after `Withdraw` |
| Commutativity / associativity | Order of operations doesn't change the result | `Merge(a, b) == Merge(b, a)` |
| Metamorphic relation | A known transformation of the input predictably transforms the output | Sorting a list twice gives the same result as sorting it once |
Worked example: FsCheck for a pricing calculator
Context: This is a .NET 8 domain method. Here is the signature and business rules:
decimal CalculateOrderTotal(IReadOnlyList<OrderLine> lines, Discount? discount)
// Rules: total is the sum of (quantity * unitPrice) per line, minus discount if present.
// Discount is either a fixed amount or a percentage, never negative, never exceeds the subtotal.
Task: Identify 4-6 properties that must hold for CalculateOrderTotal for ANY valid input, then write them as FsCheck property tests using xUnit + FsCheck.Xunit.
Constraints:
- Properties must be true for all valid inputs, not just the examples I gave.
- Include a custom generator (Arbitrary) that only produces valid OrderLine and Discount values — invalid combinations should be excluded at generation time, not filtered in the property.
- No property should just re-implement the function under test in different words.Notice the constraint against "re-implementing the function under test" — this is the most common way property-based tests go wrong. A property like `total should equal sum(lines) - discount` that mirrors the implementation line-for-line proves nothing; a good property checks something orthogonal, like "total is never negative" or "total with a 0% discount equals total with no discount at all."
When a property test fails, FsCheck automatically shrinks the counterexample to the smallest input that still fails. Always paste the shrunk failure back to the model — "this input broke the property, here's why" — rather than re-describing the bug from scratch.
Common pitfalls
- Generators that are too permissive — if your Arbitrary produces mostly invalid inputs, FsCheck spends its budget rejecting them instead of finding real bugs. Constrain generation, don't filter after the fact.
- Properties that are trivially true for all inputs (e.g. "result is not null") — technically a property, but low signal. Aim for properties that would actually fail if the implementation had a real bug.
- Treating property tests as a replacement for example-based tests instead of a complement — keep your specific regression tests for known bugs; add properties for the invariants around them.
- Not running enough iterations in CI — the default sample count (usually 100) can miss rare edge cases. For safety-critical logic, increase it explicitly.
Property-based testing pairs naturally with mutation testing — properties are what actually kill the mutants that example-based tests miss. The Testing & Reliability module in the Advanced program covers both together, along with when property-based testing isn't worth the setup cost (simple CRUD, UI glue code).