Skip to main content
ANVISoftware Solutions
Lesson 7 of 13Intermediate15 min

Test Data

By the end of this lesson

Build readable test data that makes the scenario obvious.

Most unit tests need an object before they can do anything. An order, a customer, an employee. Building that object is the part of a test that quietly gets out of hand.

The problem is not length for its own sake. It is that a reader cannot see which value the test depends on. If an order is constructed with nine arguments and the assertion is about one of them, the other eight are noise that the reader still has to scan.

A test data builder fixes that by giving every field a valid default and letting each test state only its difference. The scenario then reads off the arrange block without any decoding.

Two tests where the important value is buried
C#
[Fact]
public void ApplyLoyaltyDiscount_ForOrderOverOneHundred_ReducesTotalByFivePercent()
{
    var order = new Order(
        Guid.NewGuid(),
        "E-1042",
        new DateOnly(2026, 3, 14),
        OrderStatus.Draft,
        quantity: 8,
        unitPrice: 25m,
        shippingFee: 4.95m,
        notes: null);

    _service.ApplyLoyaltyDiscount(order);

    Assert.Equal(190m, order.Total);
}

[Fact]
public void Submit_WhenOrderIsCancelled_Throws()
{
    var order = new Order(
        Guid.NewGuid(),
        "E-1042",
        new DateOnly(2026, 3, 14),
        OrderStatus.Cancelled,
        quantity: 8,
        unitPrice: 25m,
        shippingFee: 4.95m,
        notes: null);

    Assert.Throws<InvalidOperationException>(() => _service.Submit(order));
}
  • The two tests differ in exactly one field: the status. Nothing about the code makes that visible, so a reader compares two blocks line by line to find it.
  • The date, the shipping fee and the notes have no bearing on either test. They are there because the constructor demands them, and every one of them is a value a reader has to decide to ignore.
  • The second test also depends on a value that is easy to get wrong silently. If somebody reorders the constructor parameters so that quantity and unitPrice swap, both tests still compile and the first one starts asserting on a different number.
  • Duplication is the smaller cost here. Add a tenth constructor parameter and every test in the class needs editing, which is the kind of change people make without reading what they are editing.

A builder: valid by default, explicit about the difference

Ordering.Domain.Tests/Builders/OrderBuilder.cs, and the two tests rewritten
C#
public class OrderBuilder
{
    private string _employeeId = "E-1042";
    private OrderStatus _status = OrderStatus.Draft;
    private int _quantity = 8;
    private decimal _unitPrice = 25m;

    public OrderBuilder WithStatus(OrderStatus status)
    {
        _status = status;
        return this;
    }

    public OrderBuilder WithLine(int quantity, decimal unitPrice)
    {
        _quantity = quantity;
        _unitPrice = unitPrice;
        return this;
    }

    public Order Build() => new Order(
        Guid.NewGuid(),
        _employeeId,
        new DateOnly(2026, 3, 14),
        _status,
        _quantity,
        _unitPrice,
        shippingFee: 4.95m,
        notes: null);
}

// -- in the test class --

[Fact]
public void ApplyLoyaltyDiscount_ForOrderOverOneHundred_ReducesTotalByFivePercent()
{
    Order order = new OrderBuilder().WithLine(quantity: 8, unitPrice: 25m).Build();

    _service.ApplyLoyaltyDiscount(order);

    Assert.Equal(190m, order.Total);
}

[Fact]
public void Submit_WhenOrderIsCancelled_Throws()
{
    Order order = new OrderBuilder().WithStatus(OrderStatus.Cancelled).Build();

    Assert.Throws<InvalidOperationException>(() => _service.Submit(order));
}
  • Every field starts at a value that produces a valid order. A test that says nothing about shipping or dates gets a working order anyway.
  • Each With method returns this, so calls can be chained. That is the only reason for the return type — there is no cleverness hiding in it.
  • The second test now says what it is about in one line: an order whose status is cancelled. The first test keeps its line values because the assertion depends on them, and that is exactly the distinction you want visible.
  • Build() returns a new instance every time it is called, so two tests can never share an object. A builder that caches and returns the same order would reintroduce the coupling this is meant to remove.
  • Only add a With method when a test needs to vary that field. A builder with a method for all nine fields on the first day is speculative work, and half of it will never be called.

What separates test data you can trust from test data that wastes an afternoon:

  • Fixed values, not random ones. A test that generates a random quantity passes 99 times and fails on the hundredth, with a value nobody can reproduce.
  • Values that mean something. An order for 8 units at 25.00 supports an assertion about 200.00. An order for 1 unit at 1.00 makes every arithmetic mistake look the same.
  • Distinct values across fields. If the quantity, the discount and the expected total are all 10, a test can pass while reading the wrong field.
  • Set only what the scenario needs. Every extra call to a With method is a claim that the field matters, and a reader will believe it.
  • A fresh object per test. Shared instances make one test's outcome depend on whether another ran first, and the runner does not promise an order.
  • A real date, written out, rather than DateTime.Now. Today's date turns a passing test into one that fails on the first of a month or at a year boundary.

Summary

  • Long inline construction hides the one field a test actually depends on
  • A builder gives every field a valid default so each test states only its difference
  • Use fixed, meaningful, distinct values, and a fresh object per test
  • Random data and DateTime.Now make failures irreproducible, which trains people to rerun rather than investigate
  • A builder is code you maintain, so introduce it when the arrange block starts obscuring the scenario

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Build an EmployeeBuilder

An Employee is constructed with an id, a first name, a last name, a job title, a hire date, an optional manager id and a salary.

Write a builder with valid defaults and With methods for only the two fields these tests need: a salary, and a manager id that can be absent.

Then write two tests: one for an employee with no manager, and one for a bonus calculation on a salary of 48,000.

Show solution

Two With methods, no more. The name and job title are never varied by these tests, so exposing them would suggest they matter.

WithNoManager reads better than WithManagerId(null). Naming the state rather than the value is worth doing whenever null means something specific in your domain — here it means the employee is at the top of the reporting line.

The salary of 48,000 belongs in the test rather than the builder, because the expected bonus is derived from it. Leaving it in the default would put the two halves of the assertion in different files.

C#
public class EmployeeBuilder
{
    private int? _managerId = 7;
    private decimal _salary = 52_000m;

    public EmployeeBuilder WithNoManager()
    {
        _managerId = null;
        return this;
    }

    public EmployeeBuilder WithSalary(decimal salary)
    {
        _salary = salary;
        return this;
    }

    public Employee Build() => new Employee(
        employeeId: 1042,
        firstName: "Priya",
        lastName: "Anand",
        jobTitle: "Sales Representative",
        hireDate: new DateOnly(2024, 6, 3),
        managerId: _managerId,
        salary: _salary);
}

[Fact]
public void HasManager_WhenManagerIdIsAbsent_ReturnsFalse()
{
    Employee employee = new EmployeeBuilder().WithNoManager().Build();

    Assert.False(employee.HasManager);
}

[Fact]
public void AnnualBonus_AtFortyEightThousand_IsFivePercentOfSalary()
{
    Employee employee = new EmployeeBuilder().WithSalary(48_000m).Build();

    Assert.Equal(2_400m, employee.AnnualBonus());
}

Think about it

The case for random test data

A colleague argues that random names, random quantities and random dates make tests stronger, because over many runs the suite explores far more inputs than anyone would write by hand.

There is something real in that argument. Where does it hold, and why is it the wrong default for the unit tests in this course?

Show solution

The real part is property-based testing, which is a deliberate technique rather than an accident. It generates many inputs, asserts a property that must hold for all of them — reversing a list twice gives the original list, a discount never produces a negative total — and, when it finds a failure, shrinks the input to the smallest case that still fails and reports it so you can reproduce it.

That is a different activity from filling in a name field with a random string. Random data scattered through ordinary tests gives you none of the benefits and one large cost: a failure you cannot reproduce. The test that broke last Tuesday ran against values that no longer exist, so you cannot investigate it, and the usual response is to rerun the build until it is green.

It also makes failure messages useless. "Expected 240.00, actual 187.50" tells you nothing when the quantity and price were invented at run time.

A reasonable position: fixed, meaningful data for unit tests; property-based tests added deliberately, as their own named tests, for logic where a general rule is easier to state than a list of examples.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

What is the main benefit of a test data builder over constructing an object inline in each test?
A test uses DateTime.Today as an order date. What is the risk?

Saved in this browser only.