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:

  1. How FsCheck writes properties, and how shrinking looks in C#.
  2. How to check an API contract with System.Text.Json.
  3. How WebApplicationFactory runs real integration tests.
  4. 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, like fc.assert in fast-check.
  • NonNegativeInt asks 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 0 in Math.Round to 2. 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 totalPaise back to total and 33000 to 330. 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.Total becomes 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 sends totalPaise.
  • 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

  1. FsCheck = property-based testing for .NET. [Property] runs a rule on random inputs and shrinks failures.
  2. Contract = the agreed shape of the data. Check it with System.Text.Json or a schema library.
  3. WebApplicationFactory = your real ASP.NET Core app, in memory, for integration tests.
  4. Characterization tests = pin what old code does today.
  5. Old vs new = compare on many random inputs. The old code is the oracle.

Common mistakes

MistakeWrong ideaCorrect ideaWhat 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

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.