Properties and Integration in .NET
By Syncfusion®, Inc. · Last updated
Property tests with FsCheck and shrinking, API contracts with System.Text.Json, integration tests with WebApplicationFactory, and characterization tests with old-vs-new checks before an AI refactor of C# code.
Properties, Contracts and Old Code, in C#
This lesson brings four ideas from the main course into .NET:
- Properties from Property-Based Testing, with FsCheck.
- Contracts from Integration and Contract Tests, with System.Text.Json.
- Integration tests with WebApplicationFactory.
- Characterization tests from Characterization Tests, before an AI refactor.
Simple lesson: the ideas do not change. The libraries do.
A Real-World Example
Think about a courier company that opens in a new country.
- The rules stay the same: check the parcel, check the address, check it arrived.
- The forms and the scanners are new.
- A good team learns the new forms quickly, because they already know why each check exists.
What You Will Learn
In this lesson, you will learn:
- How FsCheck writes properties, and how shrinking looks in C#.
- How to check an API contract with System.Text.Json.
- How WebApplicationFactory runs real integration tests.
- How to pin old C# code before a refactor, and compare old with new.
Properties With FsCheck
In a Real Project
using FsCheck;
using FsCheck.Xunit;
public class DiscountProperties
{
[Property]
public bool ZeroPercentKeepsThePrice(NonNegativeInt paise)
{
decimal price = paise.Get / 100m;
return Discount.Apply(price, 0) == price;
}
}
[Property]runs the method on many random inputs, likefc.assertin fast-check.NonNegativeIntasks FsCheck for whole numbers of 0 or more.- When a rule fails, FsCheck shrinks the input to the smallest one that still fails.
In this lab, xUnit is real, but FsCheck is not installed. A small property runner with a fixed seed stands in for it. It tries random inputs, and halves a failing input until it finds the smallest one. Check the FsCheck docs for the exact attribute names in the version you install.
Run a Property Here
A property, and shrinking
using System;
// The AI rounds to whole rupees. The rule: 0% off keeps the price.
static decimal ApplyDiscount(decimal price, int percent) =>
Math.Round(price * (100 - percent) / 100, 0, MidpointRounding.AwayFromZero);
var random = new Random(42);
for (int run = 1; run <= 100; run++)
{
long paise = random.Next(0, 10_000_000);
if (ApplyDiscount(paise / 100m, 0) == paise / 100m) continue;
Console.WriteLine($"Failed after {run} runs. First failing price: ₹{paise / 100m}");
// Shrink: halve the price while the rule still fails.
while (paise > 1 && ApplyDiscount(paise / 2 / 100m, 0) != paise / 2 / 100m)
{
paise /= 2;
Console.WriteLine($" shrunk to ₹{paise / 100m}");
}
Console.WriteLine($"Smallest failing price: ₹{paise / 100m}");
return;
}
Console.WriteLine("The rule held for 100 random prices.");
Press Run.
- The output: a big failing price, then smaller and smaller ones.
- The last line: a tiny price that still fails. Rounding to whole rupees loses its paise.
- Fix it: change the
0inMath.Roundto2. Run again.
Shares Must Add Up
A sum property finds lost paise
using System;
using System.Linq;
// The AI's splitBill loses the extra paise.
static decimal[] SplitBill(decimal total, int people)
{
decimal share = Math.Floor(total * 100 / people) / 100;
return Enumerable.Repeat(share, people).ToArray();
}
var random = new Random(42);
for (int run = 1; run <= 300; run++)
{
decimal total = random.Next(0, 1_000_001) / 100m;
int people = random.Next(1, 13);
decimal sum = SplitBill(total, people).Sum();
if (sum != total)
{
Console.WriteLine($"Counterexample after {run} runs: ₹{total} for {people} people.");
Console.WriteLine($"The shares add up to ₹{sum}. {total - sum} is lost.");
return;
}
}
Console.WriteLine("The shares always added up.");
Press Run.
- The output: a counterexample. The shares add up to less than the bill.
- Why it matters: a person might never try that exact bill. The property tried 300.
Simple lesson: a property is a rule for every input. The tool finds the input that breaks it.
Contracts With System.Text.Json
The server renamed total to totalPaise. A contract check catches it before the page breaks.
Check a response against the contract
using System;
using System.Collections.Generic;
using System.Text.Json;
string response = """{ "id": 7, "totalPaise": 33000, "currency": "INR" }""";
using var doc = JsonDocument.Parse(response);
var order = doc.RootElement;
var problems = new List<string>();
foreach (var field in new[] { "id", "total", "currency" })
if (!order.TryGetProperty(field, out _)) problems.Add($"{field} is missing");
if (order.TryGetProperty("total", out var total) && total.ValueKind != JsonValueKind.Number)
problems.Add("total must be a number");
Console.WriteLine(problems.Count == 0 ? "Matches the contract" : "Breaks the contract: " + string.Join(", ", problems));
Press Run.
- The output: "Breaks the contract: total is missing".
- Now try: change
totalPaiseback tototaland33000to330. Run again.
In a real project, teams often keep the contract as a JSON Schema or an OpenAPI file. A library checks data against it. Check every response in a contract test. Check your test fakes against the same contract.
Simple lesson: the contract is the written shape. Check real data and fakes against it.
Integration Tests With WebApplicationFactory
In a Real Project
WebApplicationFactory starts your real ASP.NET Core app in memory, for tests. Requests go through the real routing, the real code and the real JSON.
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderApiTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();
[Fact]
public async Task OrderSevenCosts330()
{
var order = await _client.GetFromJsonAsync<OrderDto>("/orders/7");
Assert.Equal(330m, order!.Total);
}
}
public record OrderDto(int Id, decimal Total, string Currency);
- No mocks: the real app answers.
- If someone renames
Total,order.Totalbecomes 0, and the test fails.
The Same Idea, in the Lab
The lab cannot start a web server. Here a plain method plays the server, and the client reads its JSON.
Real client, real server code, no mock
using System;
using System.Text.Json;
// The server code after the backend change.
static string HandleGetOrder(int id) => JsonSerializer.Serialize(new { id, totalPaise = 33000, currency = "INR" });
// The client code: shows the total on the order page.
static string OrderTotalText(int id)
{
using var doc = JsonDocument.Parse(HandleGetOrder(id));
return "₹" + doc.RootElement.GetProperty("total").GetDecimal().ToString("0.00");
}
try
{
Console.WriteLine(OrderTotalText(7));
}
catch (Exception e)
{
Console.WriteLine($"Integration test fails: {e.GetType().Name}");
}
Press Run.
- The output: "Integration test fails: KeyNotFoundException".
- Why: the client asks for
total, but the server now sendstotalPaise. - A mocked test would still pass, because the mock keeps the old shape.
Simple lesson: WebApplicationFactory runs the real app, so real mismatches show up.
Characterization Tests in C#
Before an agent refactors old C# code, pin what it does today. Then compare old and new on many inputs.
Old vs new on 500 random orders
using System;
using System.Linq;
static decimal R2(decimal x) => Math.Round(x, 2, MidpointRounding.AwayFromZero);
// The old code: GST is rounded on each line.
static decimal OldPrice((decimal Price, int Qty)[] lines) =>
lines.Sum(l => R2(l.Price * l.Qty) + R2(R2(l.Price * l.Qty) * 18 / 100));
// The agent's refactor: GST rounded once, on the total.
static decimal NewPrice((decimal Price, int Qty)[] lines)
{
decimal sub = lines.Sum(l => R2(l.Price * l.Qty));
return sub + R2(sub * 18 / 100);
}
var random = new Random(42);
for (int run = 1; run <= 500; run++)
{
var lines = Enumerable.Range(0, random.Next(1, 4))
.Select(_ => (Price: random.Next(1, 2001) / 100m, Qty: random.Next(1, 4)))
.ToArray();
if (OldPrice(lines) != NewPrice(lines))
{
Console.WriteLine($"Different after {run} orders:");
Console.WriteLine($" lines: {string.Join(", ", lines.Select(l => $"{l.Qty} × ₹{l.Price}"))}");
Console.WriteLine($" old ₹{OldPrice(lines)}, new ₹{NewPrice(lines)}");
return;
}
}
Console.WriteLine("Old and new matched on 500 random orders.");
Press Run.
- The output: an order where old and new differ by a paisa.
- Why: rounding each line, and rounding the total once, can give different answers.
- The old code is the oracle. Any difference is a change in behaviour.
Keep quirks on purpose. The old code may charge at least ₹1, or round GST on each line. Pin each quirk in a test, and write down why. Change it only in a separate ticket.
Simple lesson: pins for known edges, old-vs-new for the rest.
TL;DR
- FsCheck = property-based testing for .NET.
[Property]runs a rule on random inputs and shrinks failures. - Contract = the agreed shape of the data. Check it with System.Text.Json or a schema library.
- WebApplicationFactory = your real ASP.NET Core app, in memory, for integration tests.
- Characterization tests = pin what old code does today.
- Old vs new = compare on many random inputs. The old code is the oracle.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| A property that copies the formula | "Check it equals price * (100 - p) / 100." | Check bounds, sums, round trips. | A wrong formula passes its own check. |
| Deserializing without checks | "If it parses, it is fine." | Check required fields and types. | total silently becomes 0. |
| Mocking HttpClient everywhere | "Integration tests are slow." | One WebApplicationFactory test per key endpoint. | A renamed field reaches production. |
| Refactoring without pins | "It is just a cleanup." | Pin first, then refactor. | GST rounding changes on every invoice. |
| Unseeded random tests | "Random is random." | Use a fixed seed, and print the counterexample. | A failure you cannot repeat. |
Check Your Understanding
Exercises
What to Remember
- FsCheck runs rules on random inputs and shrinks failures.
- Check every API response, and every fake, against the contract.
- WebApplicationFactory runs your real app in tests.
- Pin old code before a refactor. Compare old and new on many inputs.
- Use a fixed seed and print the counterexample.
Further Reading
- FsCheck documentation: properties and generators for .NET.
- Integration tests in ASP.NET Core, Microsoft Learn: WebApplicationFactory step by step.
- System.Text.Json overview, Microsoft Learn: reading JSON in .NET.
Next: .NET Gates, Security and Capstone. You will build a test-diff guard and a secret scanner in C#, wire dotnet test and Stryker.NET into CI, and judge AI changes.