Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 8 · 8. Testing, Web APIs, Security, and Production Engineering · Lesson 22 of 24

    Unit, Integration, and Contract Tests That Protect Behavior

    What are we trying to protect?

    A green test is useful only if its failure would identify a promise that matters. Our order contains two books at 25 each. Its subtotal is 50. A requested discount of 80 must produce a total of 0 and an applied discount of 50. Later, a separate client reads these two amounts from JSON. Those are two different risks: incorrect arithmetic and an incompatible message.

    This lesson completes the order example rather than leaving Order and OrderLine undefined. You will derive boundary cases, run a small assertion program, inspect real JSON serialization, and explain which evidence is still missing. The programs use .NET 10 and no added packages. They do not contact a database, start a server, read files, or charge anyone.

    Choose the smallest useful boundary

    A useful unit test is isolated, repeatable and self-checking. Arrange the inputs, perform the operation, then compare its result with an independently chosen expectation. Name the condition and expected behavior. Executed lines alone do not establish that the assertions protect the right outcome. Microsoft: unit-testing practices

    Integration tests connect real components and verify the boundary between them. ASP.NET Core's integration-testing guidance covers an application's request pipeline and infrastructure. Coverage depends on what the test actually runs; replacing a database with a list cannot verify that database's mapping or transactions. Microsoft: integration tests

    Consumer-driven contracts record interactions the consumer needs and verify the provider against those expectations. Pact separates consumer tests from provider verification. A locally passing serializer check is only part of that wider workflow. Pact: consumer and provider verification

    For this order, ask three concrete questions. Does a discount larger than 50 leave a negative total? Does the real serializer write numeric fields named total and appliedDiscount? Can the client's parser still read a response after a harmless extra field is introduced? Answer each with the narrowest program that exposes that failure. A hundred extra arithmetic cases will not detect a renamed JSON property.

    Unit, integration and contract describe different aspects of a test. A real serializer connected to our DTO is a narrow component integration; an assertion about the two client-required fields also checks a message contract. Neither label means that an HTTP request crossed a socket. The final section names that boundary explicitly.

    Define the policy before the assertions

    The exercise has one order line. Quantity must be positive, price must be nonnegative, and a SKU must contain non-whitespace text. The requested discount must be nonnegative. A valid request applies at most the subtotal. Each ApplyDiscount call prices the original order; calling it twice does not stack two discounts. These are deliberate exercise rules, not universal requirements for every commerce system.

    Compute expected values on paper first. For subtotal 50: request 0 gives total 50 and applied 0; request 20 gives 30 and 20; request 50 gives 0 and 50; request 80 still gives 0 and 50. The nearby cases 49.99 and 50.01 check either side of the cap. A negative request is rejected instead of being silently converted into a price increase.

    Notice the difference between the applied discount being capped at 50 and the total having a lower bound of zero. Saying that a total is “capped at zero” is misleading because a cap usually describes an upper bound. The subtotal is not changed by a quote. These facts make both the code and the test's intention unambiguous.

    Complete learner program 1: an observable pricing rule

    Create the DiscountWalkthrough folder and save the following three files in it. The project has ordinary executable assertions: a failed check throws and produces a nonzero process exit. This is intentionally a small dependency-free harness, not an xUnit project; dotnet test is not the command for it. A team can later move the same arrange, act and assert steps into its chosen test runner.

    DiscountWalkthrough.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <InvariantGlobalization>true</InvariantGlobalization>
      </PropertyGroup>
    </Project>

    Order.cs

    C#
    namespace TestingLesson;
    
    public sealed record OrderLine
    {
        public string Sku { get; }
        public int Quantity { get; }
        public decimal UnitPrice { get; }
    
        public OrderLine(string sku, int quantity, decimal unitPrice) =>
            (Sku, Quantity, UnitPrice) = (sku, quantity, unitPrice);
    }
    public sealed record DiscountResult(decimal Total, decimal AppliedDiscount);
    
    public sealed class Order
    {
        public decimal Subtotal { get; }
    
        private Order(decimal subtotal) => Subtotal = subtotal;
    
        public static Order Create(OrderLine line)
        {
            ArgumentNullException.ThrowIfNull(line);
            if (string.IsNullOrWhiteSpace(line.Sku))
                throw new ArgumentException("A SKU is required.", nameof(line));
            if (line.Quantity <= 0)
                throw new ArgumentOutOfRangeException(nameof(line), "Quantity must be positive.");
            if (line.UnitPrice < 0m)
                throw new ArgumentOutOfRangeException(nameof(line), "Price must not be negative.");
            return new Order(line.Quantity * line.UnitPrice);
        }
    
        // Each call prices the original order; it does not stack discounts.
        public DiscountResult ApplyDiscount(decimal requestedDiscount)
        {
            if (requestedDiscount < 0m)
                throw new ArgumentOutOfRangeException(nameof(requestedDiscount));
            decimal applied = Math.Min(requestedDiscount, Subtotal);
            return new DiscountResult(Subtotal - applied, applied);
        }
    }

    Program.cs

    C#
    using TestingLesson;
    
    // Arrange: two books at 25 each, with no database or clock.
    var order = Order.Create(new OrderLine("book", 2, 25m));
    // Act: request more discount than the order is worth.
    var result = order.ApplyDiscount(80m);
    // Assert: these two facts describe the same pricing outcome.
    if (result.Total != 0m || result.AppliedDiscount != 50m)
        throw new InvalidOperationException("The discount was not capped correctly.");
    
    Console.WriteLine($"subtotal: {order.Subtotal}");
    Console.WriteLine($"total: {result.Total}");
    Console.WriteLine($"applied discount: {result.AppliedDiscount}");
    Console.WriteLine("PASS: discount exceeds subtotal");

    From the parent directory, run the program with the .NET 10 SDK:

    Text
    dotnet run --project DiscountWalkthrough/DiscountWalkthrough.csproj -c Release

    Expected output for these fixed inputs:

    expected.stdout

    Text
    subtotal: 50
    total: 0
    applied discount: 50
    PASS: discount exceeds subtotal

    Read the control flow from the public boundary. OrderLine is input data; its constructor does not validate it. Validation happens in Order.Create, which computes one subtotal after rejecting unusable inputs. ApplyDiscount returns a new result rather than changing that subtotal. A total-only assertion for request 80 can pass an implementation that always returns zero. Check both fields across several cases: request 20 must return total 30 and applied discount 20. That case exposes the always-zero-total defect; checking the applied amount alone would not necessarily do so.

    The example preserves the original Order.Create, OrderLine and ApplyDiscount call shape, including the quantity and unitPrice named constructor arguments. It supplies the missing implementation and replaces the unexplained Fact and Assert dependencies with visible assertions. It does not claim to test a production order repository.

    What makes a regression test discriminating?

    Imagine removing the Math.Min cap. With request 80, the defective calculation produces applied 80 and total minus 30. The original worked case catches that mistake. Now imagine changing the function to always return zero total and 50 applied. The same case would pass, but the request-20 case fails. This is why a memorable happy-path example is not a complete boundary suite.

    Do not calculate the expected result by copying the implementation's Math.Min expression into the test. If both copies share the same defect, agreement tells you little. The six expected triples above are small enough to review independently. They cover meaningful regions of this policy without enumerating every possible decimal value.

    For invalid input, check the promised exception and then check a valid operation still behaves normally. The separate semantic harness exercises negative discount, null line, blank SKU, invalid quantities, negative price, zero-price items and a subtotal that cannot fit into decimal. Its two sequential quotes of 20 and 10 must produce totals 30 and 40, demonstrating the stated non-stacking policy.

    A negative-control assertion intentionally compares zero with minus 30 and verifies that the helper rejects it. That catches an assertion helper accidentally implemented as a no-op. It is a small sanity check, not evidence that every possible implementation mutation was tested.

    Complete learner program 2: the message a client can read

    Suppose a receipt panel needs only two nonnegative numeric properties: total and appliedDiscount. It ignores additional properties. In this exercise, changing total to amount or changing the number 30 into the string “30” breaks that consumer. Reordering properties does not. Those choices define this consumer's actual contract; another client could have different needs.

    System.Text.Json supports explicit JSON property names through JsonPropertyName. The attribute applies to reading and writing and overrides a naming policy. Here explicit names make the DTO's wire spelling visible beside its C# properties. Microsoft: JSON property customization

    Create a sibling ContractWalkthrough folder. Save these three files there, keeping DiscountWalkthrough beside it. The Compile link reuses the exact Order.cs file from program 1; do not copy a second version and let the two drift apart. PriceReply is the small message DTO, while Order remains the pricing implementation.

    ContractWalkthrough.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <InvariantGlobalization>true</InvariantGlobalization>
      </PropertyGroup>
      <ItemGroup>
        <Compile Include="../DiscountWalkthrough/Order.cs" Link="Order.cs" />
      </ItemGroup>
    </Project>

    PriceContract.cs

    C#
    using System.Text.Json;
    using System.Text.Json.Serialization;
    
    namespace TestingLesson;
    
    public sealed record PriceReply(
        [property: JsonPropertyName("total")] decimal Total,
        [property: JsonPropertyName("appliedDiscount")] decimal AppliedDiscount);
    
    public static class PriceProvider
    {
        public static string Serialize(DiscountResult result) =>
            JsonSerializer.Serialize(new PriceReply(result.Total, result.AppliedDiscount));
    }
    
    public static class PriceConsumer
    {
        public static PriceReply Read(string json)
        {
            using JsonDocument document = JsonDocument.Parse(json);
            JsonElement root = document.RootElement;
            return new PriceReply(ReadAmount(root, "total"), ReadAmount(root, "appliedDiscount"));
        }
    
        private static decimal ReadAmount(JsonElement root, string name)
        {
            if (root.ValueKind != JsonValueKind.Object ||
                !root.TryGetProperty(name, out JsonElement value) ||
                value.ValueKind != JsonValueKind.Number ||
                !value.TryGetDecimal(out decimal amount) || amount < 0m)
                throw new FormatException($"Expected nonnegative numeric {name}.");
            return amount;
        }
    }

    Program.cs

    C#
    using TestingLesson;
    
    var order = Order.Create(new OrderLine("book", 2, 25m));
    string wire = PriceProvider.Serialize(order.ApplyDiscount(80m));
    PriceReply read = PriceConsumer.Read(wire);
    if (read.Total != 0m || read.AppliedDiscount != 50m)
        throw new InvalidOperationException("The consumer read the wrong amounts.");
    Console.WriteLine(wire);
    Console.WriteLine($"consumer total: {read.Total}");
    Console.WriteLine($"consumer applied discount: {read.AppliedDiscount}");
    Console.WriteLine("PASS: local price message contract");
    Text
    dotnet run --project ContractWalkthrough/ContractWalkthrough.csproj -c Release

    Expected output for this fixture:

    expected.stdout

    Text
    {"total":0,"appliedDiscount":50}
    consumer total: 0
    consumer applied discount: 50
    PASS: local price message contract

    The parser intentionally demands a JSON object and numeric, nonnegative amounts. It does not coerce quoted numbers. It accepts unrelated additional fields because it reads the two named fields only. The JSON document is kept alive while those fields are read. Malformed JSON is a parsing failure; a parseable response missing required fields is a contract failure.

    There is no simulated serializer in this program. PriceProvider calls the actual System.Text.Json serializer, then PriceConsumer parses its output. Nevertheless, both components are local code in one exercise. No HTTP status, content type, route, authentication middleware or remote deployment was exercised. The fixed output is convenient for checking the demonstration; compatibility assertions inspect fields and numeric values rather than depending on whitespace or property order.

    A separate harness should reuse the actual learner code

    The companion PDF appendix includes the complete TestingSemanticTests source and project file. This separate project uses Compile links to Order.cs and PriceContract.cs. Its seven groups cover the six pricing cases, rejection and non-stacking, construction boundaries, real provider serialization, a consumer fixture and additive compatibility, incompatible messages, and the assertion negative control. Run it with dotnet run --project TestingSemanticTests/TestingSemanticTests.csproj -c Release from the same parent directory.

    The provider test parses the actual serialized output and inspects the required numbers. The consumer test also reads a hand-written fixture independently of the provider. That prevents a provider and consumer changed together from becoming the only source of truth. The fixture with currency added and properties reordered must still parse. Renamed, quoted-number, negative-number, array-root and malformed fixtures must be rejected.

    In a real two-team release process, record which consumer version requires those fields and verify the corresponding provider version before release. Merely updating a fixture to make a failed test green changes the promise being tested. Ask whether the old client still works, not just whether the latest two branches agree.

    Practice with worked solutions

    1. Add one boundary without copying the implementation

    Task: write the expected result for a discount of 49.99 and explain the defect it could catch. Solution: the applied amount is 49.99 and the total is 0.01. A defective rule that treats all “almost 50” discounts as exactly 50 would fail this assertion. The harness includes this fixture. For 50.01, the applied amount is 50 and the total is zero; the extra 0.01 cannot reduce the total further.

    2. Diagnose a failing client after a harmless refactor

    Task: the provider changes the total property to amount, but the receipt panel is not deployed. Does the discount unit test catch the problem? Solution: no. The amounts can remain mathematically correct. PriceConsumer still requires total, so the renamed-field fixture is rejected. Keep the old wire name or agree a compatible transition with the consumer. Renaming only the internal C# property can remain compatible if its explicit JSON name stays total.

    3. Choose evidence for a database defect

    Task: the order's persisted price comes back rounded incorrectly from a database column. The six arithmetic cases pass. What should you add? Solution: a focused integration test against the actual selected database provider, using isolated test data, writes a boundary price and reads it back through the real mapping. A dictionary-backed substitute cannot reveal that column's precision. This lesson supplies a test design, not a database program or evidence that such a test ran.

    4. Remove a brittle mock without losing the useful check

    Task: a test demands that a private helper named CalculateDiscount is called exactly once. A refactor inlines the helper and the test fails although the quote is unchanged. Solution: assert the public result for request 20 and request 80. Keep a double only at a genuine external boundary when its behavior is the risk under examination. In these programs there is no external effect to replace, so mocking Order would hide the very policy we want to examine.

    Interview explanation and honest coverage

    A concise answer is: “I choose cases from the rule and failure risk. I check the discount policy directly, connect the real serializer to the DTO for message shape, and verify the consumer against explicit fixtures. Database mapping and a hosted HTTP pipeline need their own integration evidence. I do not infer those from local green tests.”

    After running the supplied programs successfully, the justified conclusion is limited to their fixed assertions on that runtime and those exact sources. The examples provide deterministic domain tests and local serialization/consumer checks. A full xUnit runner setup, hosted API integration, independent provider verification, persistence, security and deployment remain separate work. This division keeps a useful green result from turning into an unsupported promise about the entire system.

    Analogy

    Everyday picture

    A museum lending desk prepares sets for travelling exhibits. One check counts objects against an approved packing list. Another passes the completed transfer slip to the receiving clerk. Every object can be present while a renamed box on the slip prevents filing. If both desks rewrite their forms together, their rehearsal can succeed while a branch using last month's form is stranded.

    Mapping. Counting models the pricing assertions against independently chosen expectations. The transfer slip models PriceReply: renaming a field can break PriceConsumer even when ApplyDiscount remains correct. The real serializer and parser exercise that handoff locally. A retained receiving form models the independent consumer fixture; updating both sides together can hide a changed promise.

    Where it stops. This rehearsal does not establish delivery to another branch. Likewise, local serialization and consumer checks do not exercise HTTP routing, a database, or an independently deployed provider. Passing fixtures protect their stated cases; they do not prove every possible implementation correct.

    Cheat sheet (PDF)

    csharp-behavior-testing-message-contracts-companion.pdf12 pages · 86 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in