Snapshot and Property-Based Testing: Extending Unit Tests with Verify and FsCheck
Property-based testing tries a rule that holds for every input with random inputs, and snapshot testing approves a large output as a whole; both complement example tests. FsCheck found the negative total that three example tests missed on its first try and shrank it to a 0 EUR cart and a 1 EUR coupon; Verify caught a property rename as a contract change. Shrinking, custom arbitraries, scrubbers and Verify 33's maintenance fee check.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2, FsCheck.Xunit.v3 3.6.0 and Verify.XunitV3 32.0.0 (why Verify is 32.0.0 and not 33.x is explained in the post).
Predict First
Bookstore hands out two kinds of coupons: percentage (READ15: 15%) and fixed amount (WELCOME10: 10 EUR). The calculation and its tests:
public enum CouponKind { Percent, FixedAmount }
public sealed record Coupon(string Code, CouponKind Kind, decimal Value);
public static class CouponCalculator
{
public static decimal DiscountFor(decimal subtotal, Coupon coupon) => coupon.Kind switch
{
CouponKind.Percent => Math.Round(subtotal * coupon.Value / 100m, 2, MidpointRounding.AwayFromZero),
CouponKind.FixedAmount => coupon.Value,
_ => throw new ArgumentOutOfRangeException(nameof(coupon)),
};
}
public sealed class CouponExampleTests
{
private static readonly Coupon TenOff = new("WELCOME10", CouponKind.FixedAmount, 10m);
private static readonly Coupon FifteenPercent = new("READ15", CouponKind.Percent, 15m);
[Theory]
[InlineData(50, 10)]
[InlineData(120, 10)]
public void Fixed_coupon_takes_ten_euros_off(decimal subtotal, decimal expected) =>
Assert.Equal(expected, CouponCalculator.DiscountFor(subtotal, TenOff));
[Fact]
public void Percent_coupon_takes_fifteen_percent_off() =>
Assert.Equal(15.00m, CouponCalculator.DiscountFor(100m, FifteenPercent));
}
All three example tests are green. Now we write a single rule: "whatever the coupon, the discount never exceeds the subtotal". What does FsCheck find when it tries this rule with random inputs?
[Property(StartSize = 1000)]
public bool Fixed_coupon_never_exceeds_the_subtotal(NonNegativeInt subtotalCents, PositiveInt couponEuros)
{
decimal subtotal = subtotalCents.Get / 100m;
Coupon coupon = new("F", CouponKind.FixedAmount, couponEuros.Get);
return CouponCalculator.DiscountFor(subtotal, coupon) <= subtotal;
}
The Answer: A Counterexample on the First Try
Falsifiable, after 1 test (10 shrinks) (13807917890731852276,6346147742430517169).
Last step was invoked with size of 991 and seed of (15223041263418627218,17832169558009990981):
Original:
(NonNegativeInt 264, PositiveInt 451)
Shrunk:
(NonNegativeInt 0, PositiveInt 1)
FsCheck found a counterexample on its first try: a fixed 451 EUR coupon on a 2.64 EUR cart. Then it shrank it in ten steps down to its simplest form: a 0 EUR cart and a 1 EUR coupon. When a fixed coupon is larger than the subtotal, the discount exceeds the subtotal and the total goes negative. The example tests did not catch this because whoever wrote them picked the "normal" carts that came to mind: 50 and 120 EUR. The fix is Math.Min(coupon.Value, subtotal); after that, all tests are green.
This post covers two complements of example-based tests: property-based testing, which scans the input space, and snapshot testing, which approves large outputs as a whole.
Property-Based Testing: Thinking in Properties
An example-based test says "for this input, this output". A property-based test says "this holds for every input", and a generator picks the inputs. The idea comes from Haskell's QuickCheck library (Koen Claessen and John Hughes, 2000); FsCheck is its .NET counterpart, and although it is written in F#, it is comfortable to use from C#.
The hard part is not the tool but finding the property. A few typical kinds of properties from Bookstore:
- Invariant: the discount never exceeds the subtotal; the total is never negative; stock never drops below zero after a reservation.
- Round-trip: an invoice that is serialized and deserialized is the same; formatting an ISBN (International Standard Book Number) and parsing it again gives the same value.
- Oracle: the new, faster price calculator gives the same result as the old one for every input.
- Metamorphic: adding a book to the cart never decreases the subtotal; the order of the lines does not change the total.
None of these properties computes the expected value; each says only something that must be true about the result. The distinction matters, and one of the pitfalls below is exactly about it.
FsCheck: Generators and Shrinking
The FsCheck.Xunit.v3 package brings the [Property] attribute for xUnit v3; 3.6.0 depends on xunit.v3.extensibility.core with the range [4.0.0, 5.0.0), so it is built for xUnit 4.0. The test method's parameters are generated. Wrapper types like NonNegativeInt and PositiveInt both constrain generation and know how to shrink: when shrinking, they move 264 towards 0 and 451 towards 1. When you write your own Gen (for example Gen.Choose(0, 50_000).Select(...)), generation works, but without a shrinker the counterexample does not get smaller; I wrote the same rule that way in my first attempt, and FsCheck showed "0 shrinks" with 2.99 EUR and a 37 EUR coupon. If you want a readable counterexample, prefer built-in types or arbitraries that have a shrinker.
StartSize = 1000 increases the size of the generated values; in a run starting with the small default sizes, the counterexample came out small anyway, like "1 and 1", and I turned this setting on to show shrinking. Each run uses a different seed; the seed values in the output are there to reproduce the same run with [Property(Replay = "...")].
FsCheck cannot generate every type automatically. When I first wrote the round-trip property for the invoice record, I got this error:
System.Exception : The type System.Collections.Generic.IReadOnlyList`1[[Bookstore.Pricing.InvoiceLine, Bookstore.Pricing, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]] is not handled automatically by FsCheck. Consider using another type or writing a generator for it.
The fix is to build the generator from parts and register it through an arbitrary class:
// We supply FsCheck with the generator for IReadOnlyList<T>
public static class InvoiceArbitraries
{
public static Arbitrary<Invoice> Invoices() =>
(from id in ArbMap.Default.GeneratorFor<Guid>()
from number in ArbMap.Default.GeneratorFor<NonNull<string>>()
from issuedAt in ArbMap.Default.GeneratorFor<DateTimeOffset>()
from email in ArbMap.Default.GeneratorFor<NonNull<string>>()
from lines in ArbMap.Default.GeneratorFor<InvoiceLine[]>()
from subtotal in ArbMap.Default.GeneratorFor<decimal>()
from discount in ArbMap.Default.GeneratorFor<decimal>()
select new Invoice(id, number.Get, issuedAt, email.Get, lines, subtotal, discount, subtotal - discount, "EUR"))
.ToArbitrary();
}
public sealed class InvoiceJsonPropertyTests
{
[Property(MaxTest = 500, Arbitrary = [typeof(InvoiceArbitraries)])]
public bool Invoice_json_round_trips(Invoice invoice)
{
string json = InvoiceJson.Serialize(invoice);
return InvoiceJson.Serialize(InvoiceJson.Deserialize(json)!) == json;
}
}
It passed with 500 random invoices in three separate runs. We compared the JSON (JavaScript Object Notation) text, because record equality compares the Invoice record's Lines list by reference; two lists with the same content would count as "different". The round-trip property found no bug here, but this is the first test to break when the serialization settings change (for example, when a converter is added).
Snapshot Testing: Approving Large Output with Verify
Verifying the twelve fields of an invoice one by one with Assert.Equal is long and bound to be incomplete: a newly added field shows up in no test. Snapshot (approval) testing writes the whole output to a file and compares later runs with that file. Simon Cropp's Verify is the standard tool for this in .NET:
[Fact]
public Task Invoice_for_two_books_with_a_fixed_coupon()
{
Invoice invoice = InvoiceFactory.Create(
"INV-2026-0042",
"ada@example.com",
[("978-0321125217", "Domain-Driven Design", 1, 55m), ("978-0134757599", "Refactoring", 2, 48m)],
new Coupon("WELCOME10", CouponKind.FixedAmount, 10m),
TimeProvider.System);
return Verify(invoice);
}
The first run always fails, because there is no approved snapshot yet. Verify writes the received output to a .received.txt file and shows it:
VerifyException : Directory: …/p12/Bookstore.Pricing.Tests
New:
- Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
Verified: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.verified.txt
Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
{
Id: Guid_1,
Number: INV-2026-0042,
IssuedAt: DateTimeOffset_1,
CustomerEmail: ada@example.com,
Lines: [
{
Isbn: 978-0321125217,
Title: Domain-Driven Design,
Quantity: 1,
UnitPrice: 55.0,
LineTotal: 55.0
},
{
Isbn: 978-0134757599,
Title: Refactoring,
Quantity: 2,
UnitPrice: 48.0,
LineTotal: 96.0
}
],
Subtotal: 151.0,
Discount: 10.0,
Total: 141.0,
Currency: EUR
}
Two details stand out. Id and IssuedAt have different values on every run, but in the snapshot they appear as Guid_1 and DateTimeOffset_1: Verify scrubs these types by default, replacing them with deterministic placeholders. If the same Guid appeared in two places, both would be Guid_1; the relationship is kept but the value is not pinned. For your own fields (like a counter that generates order numbers) there are settings such as ScrubMember and ScrubLinesContaining. Second, I read the snapshot file and decided it was correct (151 − 10 = 141); I saved the file as .verified.txt, and the next run passed. This step is a human decision, and the whole value of snapshot testing lies in it.
When a Snapshot Changes: Review
During a refactoring, someone renamed CustomerEmail in Invoice to Email. The build passed, all example tests passed. The snapshot test:
NotEqual:
- Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
Verified: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.verified.txt
Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
{
Id: Guid_1,
Number: INV-2026-0042,
IssuedAt: DateTimeOffset_1,
Email: ada@example.com,
If the invoice is an API (Application Programming Interface) response, this is a contract change that breaks clients. The snapshot test does not just find the change, it brings it into code review: because the .verified.txt file is committed, whoever accepts the change also shows the snapshot difference in the pull request. If the change is intentional, the new snapshot is approved; if not, the code is reverted. On a development machine Verify tries to open the difference in a diff tool; in CI (Continuous Integration) and in the experiments for this post I turned that off with the DiffEngine_Disabled=true environment variable.
Verify 33 and the Maintenance Fee
This post uses Verify.XunitV3 32.0.0 rather than the latest 33.3.4. The reason is a change that came with 33.x: according to Verify's maintenance fee document, releases published after 1 September 2026 are subject to a monthly maintenance fee for organizations that use them in revenue-generating activities with annual gross revenue of 10,000 USD or more; from v33, a build step called SponsorCheck checks this. When I added 33.3.4, the build failed with code SC021 and the message "No license specified. Package 'Verify' requires a SponsorCheck license property." The error text lists the options: a GitHub Sponsors account, a time-bounded license, or a time-bounded exemption declaration (open source projects without revenue, small-revenue organizations and individuals are exempt). According to the document, the check adds no run-time dependency and makes no network calls; the source code license does not change.
This is a matter of licensing and declarations; it is not something to declare an exemption for on your behalf. That is why the examples in this post were run with 32.0.0 (26 August 2026), which is outside that scope; it worked without problems with xUnit 4.0.2. Before moving to 33.x in your own project, determine your organization's situation and, if needed, add the relevant MSBuild property to Directory.Build.props. Together with the FluentAssertions license in post 03 and Moq's SponsorLink incident in post 07, this is the third example: reading the release notes of test dependencies matters as much as for production dependencies.
Where It Pays Off, Where It Is Noise
Property-based testing is valuable for calculations with a clear invariant (price, discount, stock, currency conversion), for parsers and serializers, and for comparing two implementations. In workflow-heavy code (save the order, send an email) properties are hard to find and the tests stay forced. Snapshot testing is valuable for large, structured output: API responses, reports, email templates, generated SQL (Structured Query Language). Verifying a small number with a snapshot is noise; Assert.Equal(141m, invoice.Total) is more readable and states its intent.
Neither replaces example-based tests. Example tests explain the rule to the reader (WELCOME10 takes 10 EUR off), the property test scans the edges of the rule, and the snapshot protects the output as a whole. In a healthy suite the three stand side by side; the best lasting outcome for the bug in Predict First is to also add the counterexample FsCheck found as an InlineData row in the example tests.
In Production
Snapshot files are reviewed like code. Commit the .verified.txt files and add .received.txt files to .gitignore. Large snapshot diffs risk being approved in a pull request without being read; keep snapshots small and focused.
Manage the duration and seed of property tests. The default of 100 tries is enough in most cases; raising MaxTest slows the suite down. Log the seed of a property test that fails in CI and reproduce it locally with Replay using the same seed. Every counterexample FsCheck finds should turn into a regression test.
Culture-independent snapshots. The decimal separator, date format and sorting depend on the culture; to keep snapshots from coming out differently on different machines, pin the culture setting we mentioned in post 04.
Common Pitfalls
Accepting a snapshot without reading it. The "test failed, rename received to verified" reflex turns snapshot testing into a machine that approves bugs. Every acceptance is a review decision; a snapshot accepted without reading behaves as if there were no test.
Writing the property as a copy of the implementation. A property like DiscountFor(s, c) == Math.Min(c.Value, s) repeats the production code and is wrong along with it. A property should not compute the result; it should say something that must be true about it: bounds, relationships, invariants.
Unscrubbed variable data. If time, Guids, sequence numbers or machine names go into a snapshot raw, the test fails on every run and the team starts accepting snapshots blindly. Add your own scrubber for fields that Verify's default scrubbers do not cover, or pin time with FakeTimeProvider as in post 09.
Snapshotting everything. A suite that verifies every domain object with snapshots produces hundreds of file changes on every refactoring; that is another form of the lack of resistance to refactoring from post 01.
A Small Experiment
★☆☆ Turn the counterexample FsCheck found into an example test: add the row "on a 5 EUR cart, WELCOME10 takes off at most 5 EUR" ([InlineData(5, 5)]) to the Fixed_coupon_takes_ten_euros_off theory and see that this row fails with the code before the fix.
★★☆ Write a metamorphic property: adding a new line to an invoice never decreases the subtotal. Which generator do you need for this? What happens if a negative price is generated, and is that a bug or a missing validation?
★★★ Call InvoiceFactory.Create with a FakeTimeProvider instead of TimeProvider.System, and make IssuedAt appear in the snapshot with its real value, unscrubbed (DontScrubDateTimes). Which is more readable, which is more robust?
In the next and final post we return to where the series started: reading old test projects written with xUnit v2 and migrating them step by step to v3 and 4.0.