10 minute read

Unit tests are read much more often than they are written.

We read them when a test fails, when production code changes, when we review a pull request, or simply when we are trying to understand what a component is supposed to do.

That makes the signal-to-noise ratio of a test important.

A test can be perfectly correct and still make the reader work too hard. The Arrange phase is usually where this happens: object construction, fake creation, constructor wiring, arbitrary constants, and other plumbing all compete for attention with the few details that actually define the scenario.

The testing style I use is built around one principle:

A unit test should describe what is special about the scenario, not everything required to construct it.

AutoFixture is what makes that practical for me.

The scenario is buried in the setup

Consider a fairly ordinary test for a payment handler:

[Test]
public async Task Should_not_charge_customer_when_order_is_already_paid()
{
    // Arrange
    var orderId = Guid.NewGuid();
    var customerId = Guid.NewGuid();

    var order = new Order
    {
        Id = orderId,
        CustomerId = customerId,
        Status = OrderStatus.Paid,
        Total = 149.90m
    };

    var paymentGateway = A.Fake<IPaymentGateway>();
    var orderRepository = A.Fake<IOrderRepository>();
    var clock = A.Fake<IClock>();
    var logger = A.Fake<ILogger<PayOrderHandler>>();

    A.CallTo(() => orderRepository.Get(orderId))
        .Returns(order);

    var sut = new PayOrderHandler(
        paymentGateway,
        orderRepository,
        clock,
        logger);

    var command = new PayOrder(orderId);

    // Act
    await sut.Handle(command);

    // Assert
    A.CallTo(() => paymentGateway.Charge(
            customerId,
            149.90m))
        .MustNotHaveHappened();
}

There is nothing particularly wrong with this test.

The objects need to exist. The handler needs its dependencies. The repository needs to return the order. The command needs an order ID. None of that setup was added just to make the example look worse than it is.

But if I ask what is special about this scenario, the answer is much shorter than the Arrange section.

The order is already paid. The repository returns that order. The payment gateway must not be called.

Everything else is context required to execute the test.

Using AutoFixture, I would write the same test more like this:

[Test]
[AutoData]
public async Task Should_not_charge_customer_when_order_is_already_paid(
    [Frozen] IPaymentGateway paymentGateway,
    [Frozen] IOrderRepository orderRepository,
    PayOrderHandler sut,
    PayOrder command,
    Order order)
{
    // Arrange
    order.Status = OrderStatus.Paid;

    A.CallTo(() => orderRepository.Get(command.OrderId))
        .Returns(order);

    // Act
    await sut.Handle(command);

    // Assert
    A.CallTo(() => paymentGateway.Charge(A<Guid>._, A<decimal>._))
        .MustNotHaveHappened();
}

The GUIDs disappeared. So did the arbitrary amount, fake construction, logger and clock variables, the handler constructor, and command construction.

What remains in Arrange is the part I want the reader to notice:

order.Status = OrderStatus.Paid;

The repository setup also remains:

A.CallTo(() => orderRepository.Get(command.OrderId))
    .Returns(order);

I could build fixture infrastructure smart enough to hide that relationship too. I usually would not.

The fact that this is the order returned when the handler looks up command.OrderId is important to understanding the scenario. Hiding that relationship would reduce the number of lines, but it would also remove useful information.

The goal is not to minimize the number of lines in Arrange. It is to make sure the lines that remain carry as much meaning as possible.

Start from a valid context

For this style to work well, the fixture needs to know how to construct a useful default context.

That does not mean every generated object has to be a perfect model of production data. It means the object graph should be structurally valid enough that a test does not have to repair unrelated details before it can describe the scenario it cares about.

If an Order normally needs an identifier, a customer, and sensible values for its required properties, the shared fixture configuration should be able to produce one.

If a test needs the order to be paid, the test should say so.

If another test needs some normally valid part of the context to be invalid, that test should make the invalid state explicit.

This creates a useful division of responsibility:

  • shared fixture configuration establishes invariants and sensible defaults;
  • the test describes what is different in this particular scenario.

In less formal terms, I want the fixture to build the ordinary world so the test can describe what is unusual about it.

This also puts a useful limit on what belongs in shared customization.

The repository setup in the previous example is not a universal invariant. It describes how the command, repository, and order relate in that test, so it stays visible.

Moving setup out of the test is useful only when that setup no longer tells the reader anything specific about the scenario.

Keep only meaningful details visible

Once AutoFixture owns most of the construction, the test signature becomes a useful place to expose the few objects the test needs to work with directly.

That is where [Frozen] becomes particularly useful.

[Frozen] means this instance matters

In the example, the test receives two frozen dependencies:

[Frozen] IPaymentGateway paymentGateway,
[Frozen] IOrderRepository orderRepository,

Mechanically, freezing a specimen tells AutoFixture to reuse that same instance when the same type is requested elsewhere. The IOrderRepository passed to the test is therefore the same instance that AutoFixture injects into PayOrderHandler.

But I also like what [Frozen] communicates to the reader.

I freeze a value because I need to work with that specific instance. I might need to configure it, as with orderRepository, or assert against it, as with paymentGateway.

If any instance is acceptable, I do not freeze it.

That makes [Frozen] a small visual signal: this parameter deserves some attention.

Literals should earn their place

The same principle applies to literal values.

The original assertion was very specific:

A.CallTo(() => paymentGateway.Charge(
        customerId,
        149.90m))
    .MustNotHaveHappened();

But this test is not saying that the gateway must not charge one particular customer 149.90m.

It is saying that the gateway must not be called at all.

So the second version says exactly that:

A.CallTo(() => paymentGateway.Charge(A<Guid>._, A<decimal>._))
    .MustNotHaveHappened();

A literal should appear in a test because the value itself matters to the behavior.

OrderStatus.Paid matters because it defines the scenario.

149.90m does not. Neither does a particular GUID.

Generated values have a secondary benefit that appeals to the mathematician in me: they stop us from repeatedly testing behavior against the same arbitrary fixed points simply because those values were easy to type.

This is not property-based testing. AutoFixture is not systematically exploring an input space, and I would not claim that generated specimens provide that kind of coverage.

If the customer ID does not matter, I do not want to hard-code one.

When a generated value exposes a failure, that often points to a domain constraint that was not represented explicitly enough. The better response is usually to model that constraint in the fixture or in the test, rather than fall back to a magic constant that happens to pass.

Construct the SUT only when construction matters

The generated test also receives the system under test directly:

PayOrderHandler sut

That is intentional.

If the test is about how PayOrderHandler behaves, its constructor is usually not interesting. Repeating the constructor and all of its dependencies in every test makes readers process details that do not help them understand the behavior.

If construction itself is part of what I am testing, I construct the object explicitly.

AutoFixture does not turn a constructor with too many dependencies into good design. If a class has an uncomfortable dependency graph, that smell still exists whether I instantiate the class manually or let AutoFixture do it for me.

Sometimes the design is worth changing. Sometimes there are good enough reasons to tolerate it. Automatic construction does not remove that trade-off; it simply avoids repeating the same constructor plumbing in every test.

What [AutoData] does

The test above uses [AutoData] from AutoFixture’s NUnit integration:

[Test]
[AutoData]
public async Task Some_test(
    SomeEntity entity,
    SomeHandler sut)

The attribute connects NUnit’s parameterized-test model to an AutoFixture Fixture. AutoFixture creates specimens for the test parameters and recursively constructs the objects they depend on.

With an auto-mocking customization, interfaces and other abstract dependencies can be supplied by a mocking library instead of preventing AutoFixture from constructing the SUT.

In my examples I use FakeItEasy, but that is not fundamental to the style. AutoFixture can integrate with other mocking libraries as well, and its xUnit integration provides the same general approach with a different test framework.

[InlineAutoData] is the companion to [AutoData]. It lets a test provide explicit inline values for the parts of a test case that deliberately vary while AutoFixture supplies the remaining parameters.

I do not need it in the paid-order example. OrderStatus.Paid is not one value in a parameterized set of cases; it is what makes this scenario what it is, so I want the test to set it explicitly in Arrange.

That distinction matters more to me than the mechanics of the attributes themselves.

The test framework defines and executes the test. AutoFixture constructs the context. The mocking library makes abstract dependencies constructible and provides convenient interaction assertions.

The test framework and mocking library can both be replaced without changing the underlying approach. AutoFixture is the part that allows construction details to disappear from tests when those details are not relevant to the behavior being tested.

AutoFixture owns construction, not the scenario

Once a fixture becomes capable of constructing complex object graphs, there is an obvious temptation to hide everything behind it.

I do not think that produces better tests.

If a reader has to understand the fixture internals just to discover that the repository returns the same order the test later modifies, the setup has not disappeared. It has simply moved somewhere harder to see.

The boundary I try to keep is simple:

Let AutoFixture handle construction. Keep the details that explain the scenario in the test.

That is why the order itself can be generated while this line remains explicit:

order.Status = OrderStatus.Paid;

It is why the repository can be generated while this relationship remains explicit:

A.CallTo(() => orderRepository.Get(command.OrderId))
    .Returns(order);

And it is why the handler can be generated while paymentGateway is frozen and visible: the test needs that particular dependency for its assertion.

The exact boundary changes from test to test. What matters is that hiding setup should remove incidental detail without hiding the information a reader needs to understand why the test behaves the way it does.

The complexity moved

There is an obvious cost to this style.

The fixture becomes shared test infrastructure.

A new contributor may open a test and wonder where an object came from, why a property has a particular value, or how a dependency was created. Complex domain models, especially object graphs with many relationships, can require substantial customization.

I have seen fixture setup become smart enough that it started to feel almost magical. That is not automatically a success.

The complexity is moved, not eliminated.

I am usually comfortable with that trade because of where the complexity ends up.

Construction rules and defaults are defined in one place, while individual tests are read again and again. I would rather pay the cost of understanding the shared infrastructure when I need to change it than pay the cost of filtering repeated construction noise every time I read a test.

There is still a boundary to protect.

Shared fixture configuration should describe things that are broadly true for the default context: invariants, required relationships, and reusable construction rules.

Scenario-specific assumptions should stay local, or be introduced through test-specific customization when they are substantial enough to deserve reusable setup of their own.

If the fixture starts encoding the details that make individual tests unique, local readability suffers and the benefit starts to disappear.

A useful side effect for coding agents

There is also a modern side effect I did not originally optimize for.

Tests with less repetitive setup give coding agents less boilerplate to reproduce incorrectly. There are fewer constructor arguments to wire, fewer arbitrary literals to invent, and fewer tokens spent rebuilding the same context around every behavior.

I would not adopt this testing style because of agents. I was using it long before that was relevant.

But reducing code that humans should not have to read also tends to reduce code that agents should not have to generate.

Recap

In this post we have seen how AutoFixture can keep unit tests focused on the scenario by taking care of the context that is necessary for the test to run but not important to understand.

The useful boundary is not between visible and hidden setup, but between what explains the scenario and what is merely construction. The first should stay in the test. The second can usually move into the fixture, together with the reusable rules that make the default context valid.

That does not remove complexity, but it puts more of the reader’s attention on the code that matters.

Reading code is expensive. We should only read code that matters.

This article was produced using an AI-assisted editorial process and was reviewed with AI assistance. Read about my editorial process.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!