Test Data with Builders, Bogus and AutoFixture
Objective
Every test needs data, and the way you build it decides what the test says. A long constructor call buries the one value that matters; random data without a seed makes a failure impossible to reproduce; fully anonymous data hides which input the assertion depends on. The goal is to build test data three ways, with MSTest 4: a Test Data Builder written by hand, Bogus with a fixed seed, and AutoFixture with AutoNSubstitute, and to know when each one makes the intent of a test clearer and when it makes it vaguer.
Use Cases
- Writing a rule test that names only the fact the rule depends on (a premium customer, an order of exactly 500) and leaves everything else at a valid default.
- Generating a few hundred realistic orders to check a property ("the discount is always one of four rates") in a way you can replay when it fails.
- Creating a service with five constructor dependencies in one line, with every interface already a substitute, when the test only cares about one of them.
- Seeding an integration test database with plausible names and e-mails.
Deep Dive
A Test Data Builder says only what matters
The rule under test: premium customers get 10%, orders of 500 or more get another 5%, and the two add up. Without a builder every test repeats the whole graph:
csharpvar order = new Order(Guid.NewGuid(),
new Customer(Guid.NewGuid(), "Ana", "[email protected]", IsPremium: true),
[new OrderLine("SKU-1", 1, 50m)]);Which of those values does the test depend on? You have to read the rule to know. A builder has a valid default and one method per fact a test wants to state:
csharppublic sealed class OrderBuilder
{
private bool _premium;
private List<OrderLine> _lines = [new("SKU-1", 1, 10m)];
public OrderBuilder Premium() { _premium = true; return this; }
public OrderBuilder Worth(decimal total) => WithLines(new OrderLine("SKU-1", 1, total));
public OrderBuilder WithLines(params OrderLine[] lines) { _lines = [.. lines]; return this; }
public Order Build() => new(
new Guid("00000000-0000-0000-0000-000000000001"),
new Customer(new Guid("00000000-0000-0000-0000-0000000000c1"), "Ana", "[email protected]", _premium),
_lines);
}csharpvar order = new OrderBuilder().Premium().Worth(500m).Build();
Assert.AreEqual(0.15m, DiscountPolicy.RateFor(order));The test now reads as the rule. Two details make a builder trustworthy. The
defaults are fixed values: a literal Guid, not Guid.NewGuid(), and no clock,
so a failing test fails the same way every time. And the number in the test is
chosen for the rule: Worth(500m) sits exactly on the threshold, while
Worth(600m) would still pass if the production check were > 500.
An Object Mother (Orders.Premium(), Orders.Bulk()) is the same idea with
named, ready-made objects. It starts fine and ends as a catalog of near-duplicates
("PremiumBulkOrderWithTwoLines"). Prefer the builder when tests need variations,
and keep an Object Mother only for a handful of stable, meaningful fixtures.
Bogus: realistic data you can replay
Bogus generates names, e-mails, addresses and numbers. Used for unit tests, its
risk is randomness: if the data changes on every run, a failure on CI is data
you will never see again. UseSeed removes the problem:
csharpusing Bogus;
public static Faker<Customer> Customers(int seed = 1234) =>
new Faker<Customer>("en")
.UseSeed(seed)
.CustomInstantiator(f => new Customer(f.Random.Guid(), f.Name.FullName(), f.Internet.Email(), f.Random.Bool()));With the same seed two fakers produce equal lists, which is a test worth writing once, because the rest of your suite relies on it:
csharpCollectionAssert.AreEqual(Fakes.Customers(7).Generate(5), Fakes.Customers(7).Generate(5));
CollectionAssert.AreNotEqual(Fakes.Customers(7).Generate(5), Fakes.Customers(8).Generate(5));A seeded faker is deterministic, not meaningful: it gives you broad coverage of the input space, not a reason. It fits property-style checks over many objects. Prove the generated data covers both branches before trusting the assertion, otherwise "all 200 orders give a valid rate" could be 200 non-premium small orders:
csharpvar orders = Fakes.Orders(42).Generate(200);
Assert.IsTrue(orders.Any(o => o.Customer.IsPremium) && orders.Any(o => !o.Customer.IsPremium));
Assert.IsTrue(orders.Any(o => o.Total >= DiscountPolicy.BulkThreshold) && orders.Any(o => o.Total < DiscountPolicy.BulkThreshold));
decimal[] allowed = [0m, 0.05m, 0.10m, 0.15m];
foreach (var order in orders) CollectionAssert.Contains(allowed, DiscountPolicy.RateFor(order));Do not recompute the rule in the test (expected = premium ? 0.1 : 0 ...): that
is a copy of the production code, and it passes when both are wrong the same way.
Bogus also fits seeding a development or integration database. The current
version is 35.6.5.
AutoFixture and AutoNSubstitute: anonymous values, automatic doubles
AutoFixture creates any object graph with arbitrary values, and
AutoNSubstituteCustomization turns every interface it meets into an NSubstitute
substitute. A service with two interface dependencies becomes one line:
csharpusing AutoFixture;
using AutoFixture.AutoNSubstitute;
var fixture = new Fixture().Customize(new AutoNSubstituteCustomization());
var repository = fixture.Freeze<IOrderRepository>(); // the instance the service receives
var email = fixture.Freeze<IEmailSender>();
var order = fixture.Create<Order>();
var sut = fixture.Create<OrderService>();
await sut.PlaceAsync(order, ct);
await repository.Received(1).SaveAsync(order, ct);
await email.Received(1).SendAsync(order.Customer.Email, Arg.Any<string>(), ct);Freeze is the key call: without it the fixture would give the service one
substitute and hand you another. Here the order is anonymous on purpose, because
nothing in the test depends on its values. That is AutoFixture at its best: the
test states "any order", and a reader knows it does not matter.
It stops being clear when the assertion does depend on a value. AutoFixture
chooses strings, numbers and booleans for you, so a test of the discount with a
fully anonymous order checks a result that depends on data it never wrote down.
Pin exactly the facts the expectation needs, with a with expression on the
record, and leave the rest anonymous:
csharpvar order = fixture.Create<Order>() with
{
Customer = fixture.Create<Customer>() with { IsPremium = true },
Lines = [new OrderLine("SKU-1", 1, 100m)],
};
Assert.AreEqual(90m, await sut.PlaceAsync(order, ct));The same fixture sets Lines = [] in the test that rejects an order without
lines: one pinned value, the thing the test is about.
On version: the stable line on NuGet is AutoFixture 4.18.1 and
AutoFixture.AutoNSubstitute 4.18.1 (the lab runs them on net10.0). Version 5.0.0-rc.1 exists but is a
release candidate, so teach and use 4.x. Restoring 4.18.1 with NSubstitute 6.2.0
prints warning NU1608 (the package declares NSubstitute below 6.0.0); the tests in
the lab run fine with 6.2.0 anyway.
When each one hides the intent
- Builder: hides nothing, but hides the defaults. Keep them small and fixed,
and name methods after domain facts (
Premium()), not fields (WithIsPremium(true)). - Bogus: hides the value a failure depends on. When a seeded test fails, print the seed and the offending object in the message, or turn the case into a builder test with that exact data.
- AutoFixture: hides what the assertion needs. If you cannot say which anonymous value the result depends on, the test is not finished: pin it.
A useful rule: a reader should be able to tell, from the test alone, which input makes the assertion true. Whatever tool you use, the values that matter appear in the test and everything else is allowed to be boring.
Trade-offs
- Builders are code you maintain. Each domain concept gets a builder, and a change to a constructor touches one builder instead of 200 tests. That is the point, and the cost: do not build one for a type used in two tests.
- Determinism versus coverage. A fixed seed gives a replayable run, but the same inputs every time. Rotating the seed per run finds more bugs and makes failures harder to reproduce unless the seed is logged. Pick a fixed seed for the normal suite and a rotating one for a nightly run that prints it.
- Realistic data is not representative data. Bogus names look real and say nothing about the edge cases (a 255-character name, an empty list). Edge cases are still written by hand.
- AutoFixture couples tests to construction. A new required constructor parameter is created automatically, so tests keep compiling, which is good, and also means a test can silently start depending on a value nobody chose. A rule test that stays fully anonymous is a smell.
- AutoFixture 5 is not stable yet. Pinning to 4.18.1 is safe today; plan for a migration when 5.x ships.
- Anonymous data does not replace a boundary.
fixture.Create<int>()will never land exactly on 500. Boundaries are chosen, not generated.