Test Data Builders in .NET: Clean, Maintainable Test Setup
Every test needs data. When your domain objects are simple, constructing them inline is fine. But as your models grow — required fields, nested objects, validation rules — test setup code becomes the dominant part of every test method. The test data builder pattern solves this by providing a fluent API for constructing objects with sensible defaults, letting each test override only the properties it cares about.
The Problem
Consider an Order entity:
public class Order
{
public int Id { get; set; }
public string CustomerId { get; set; }
public string CustomerEmail { get; set; }
public List<OrderLine> Lines { get; set; }
public OrderStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ShippedAt { get; set; }
public Address ShippingAddress { get; set; }
}
Without a builder, every test must construct the full object:
[Fact]
public void CancelOrder_PendingOrder_SetsStatusToCancelled()
{
var order = new Order
{
Id = 1,
CustomerId = "cust-123",
CustomerEmail = "[email protected]",
Lines = new List<OrderLine>
{
new OrderLine { ProductId = "prod-1", Quantity = 2, UnitPrice = 9.99m }
},
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow,
ShippingAddress = new Address
{
Line1 = "123 Test St",
City = "London",
PostCode = "SW1A 1AA"
}
};
_service.Cancel(order);
Assert.Equal(OrderStatus.Cancelled, order.Status);
}
The test is about cancellation, but 90% of the code is building an order. Worse, this setup is duplicated across dozens of tests. When Order gains a new required field, you're updating every single one.
The Builder Pattern
public class OrderBuilder
{
private int _id = 1;
private string _customerId = "cust-default";
private string _customerEmail = "[email protected]";
private List<OrderLine> _lines = new()
{
new OrderLine { ProductId = "prod-1", Quantity = 1, UnitPrice = 10.00m }
};
private OrderStatus _status = OrderStatus.Pending;
private DateTime _createdAt = new(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private DateTime? _shippedAt;
private Address _shippingAddress = new()
{
Line1 = "1 Test Lane",
City = "London",
PostCode = "SW1A 1AA"
};
public OrderBuilder WithId(int id) { _id = id; return this; }
public OrderBuilder WithCustomerId(string id) { _customerId = id; return this; }
public OrderBuilder WithCustomerEmail(string email) { _customerEmail = email; return this; }
public OrderBuilder WithLines(List<OrderLine> lines) { _lines = lines; return this; }
public OrderBuilder WithStatus(OrderStatus status) { _status = status; return this; }
public OrderBuilder WithCreatedAt(DateTime date) { _createdAt = date; return this; }
public OrderBuilder WithShippedAt(DateTime? date) { _shippedAt = date; return this; }
public OrderBuilder WithShippingAddress(Address address) { _shippingAddress = address; return this; }
public Order Build() => new()
{
Id = _id,
CustomerId = _customerId,
CustomerEmail = _customerEmail,
Lines = _lines,
Status = _status,
CreatedAt = _createdAt,
ShippedAt = _shippedAt,
ShippingAddress = _shippingAddress
};
}
Now the test becomes focused:
[Fact]
public void CancelOrder_PendingOrder_SetsStatusToCancelled()
{
var order = new OrderBuilder()
.WithStatus(OrderStatus.Pending)
.Build();
_service.Cancel(order);
Assert.Equal(OrderStatus.Cancelled, order.Status);
}
The test clearly communicates what matters: the order is pending, and after cancellation, it should be cancelled. Every other field uses a sensible default.
Convenience Methods for Common Scenarios
Add methods that represent business concepts rather than just setting fields:
public class OrderBuilder
{
// ... fields and basic methods ...
public OrderBuilder ThatHasBeenShipped()
{
_status = OrderStatus.Shipped;
_shippedAt = _createdAt.AddDays(2);
return this;
}
public OrderBuilder WithMultipleItems(int count = 3)
{
_lines = Enumerable.Range(1, count)
.Select(i => new OrderLine
{
ProductId = $"prod-{i}",
Quantity = i,
UnitPrice = 10.00m * i
})
.ToList();
return this;
}
public static implicit operator Order(OrderBuilder builder) => builder.Build();
}
Usage becomes even more readable:
[Fact]
public void CancelOrder_ShippedOrder_ThrowsInvalidOperationException()
{
Order order = new OrderBuilder().ThatHasBeenShipped();
var act = () => _service.Cancel(order);
Assert.Throws<InvalidOperationException>(act);
}
The implicit conversion operator lets you assign a builder directly to an Order variable, removing the .Build() call when it adds noise.
Combining with Bogus
For tests that need realistic data rather than predictable defaults, combine builders with Bogus (covered in another article):
public class OrderBuilder
{
private static readonly Faker _faker = new("en_GB");
public OrderBuilder WithRandomCustomer()
{
_customerId = _faker.Random.Guid().ToString();
_customerEmail = _faker.Internet.Email();
return this;
}
}
Tips for Effective Builders
- One builder per aggregate root. Don't build separate builders for every nested object unless they're independently complex.
- Defaults should produce valid objects. The zero-argument
Build()should return an object that passes validation. - Name methods after business concepts (
ThatHasBeenShipped) rather than just field names when it improves readability. - Keep builders in a shared test utilities project so all test projects can reuse them.
- Update defaults in one place when the domain model changes — that's the whole point.
The builder pattern is a small upfront investment that pays for itself quickly. Once your test suite has more than a handful of tests, the reduced duplication and improved readability make a tangible difference to how confidently you can maintain and extend your tests.