# Properties and Integration in .NET

Course: [Testing & Verification](https://www.devtutorials.ai/learn/testing-verification) · Section: Testing in .NET
Source: https://www.devtutorials.ai/learn/testing-verification/dotnet/properties-and-integration-dotnet

> 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

```csharp
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

```csharp runnable title="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

```csharp runnable title="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.

```csharp runnable title="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.

```csharp
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.

```csharp runnable title="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.

```csharp runnable title="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

| 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](https://fscheck.github.io/FsCheck/): properties and generators for .NET.
- [Integration tests in ASP.NET Core, Microsoft Learn](https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests): WebApplicationFactory step by step.
- [System.Text.Json overview, Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/overview): 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.
