David Brandt · trains of thought

Processes

Tests are written for the maintainer

The unit testing methodology I considered good practice, before the advent of AI-assisted development

Seeded 2010 Written October 2026

A note before you start: this describes software development as it was before AI-driven development replaced human development. Humans wrote the code, humans wrote the tests, and humans had to maintain both. That world is gone. My agents write my tests now, test-first, and not one of them has complained that it isn't how they think. What follows is the last unit testing methodology I wrote for human hands, published as a record of how it was done when we still did it ourselves.


A test is written once and read many times: in code review, when it fails, and when someone changes the code it protects a year later. That last reader decides whether the test survives. If it is hard to update, because it was never written to be maintained and tests the implementation rather than the intent, it gets commented out or deleted. Most of the unit tests I have had to deal with were written for none of those readers.

They were written once, by someone who had already done the fun part. The implementation was finished, its author believed it was correct, and the test was hammered together afterwards, by copy-paste, in whatever mocking and testing framework the project happened to use. The result was rarely short, because the mocks were a sprawl of incomprehensible goo, and it was illegible, because it had no organization at all. Whether a test checked the intent of the code or its implementation was anyone's guess. Usually it checked a subset of the implementation.

Nobody really knew what those tests verified. They had not been written first, so none of them had ever been seen to fail against the code they were testing, and few of them verified that they exercised the branch they were named after. When anyone ran a coverage tool at all, they ran it over the whole project and took line coverage per folder as a sign that things were well tested.

The code around the tests was no better. Shared fixtures existed, but they were odd and only partly reusable, and neither the fixtures nor the test doubles had tests of their own. Across a codebase there was no consistency, because nobody liked writing tests and nobody cared how they looked.

I don't think the answer to that is TDD. Writing the test before the code is a nightmare for most people; it is not how we think. The answer I settled on was to make tests so uniform that writing a good one takes no inspiration, and reading one takes no effort.

I eventually wrote that down as a unit testing style guide for C#. It is the last version of the methodology I wrote for humans: C# was the last programming language I used while people were still the primary readers and writers of automated tests. This article describes what is in it. It is deliberately strict, and some of it will look like ceremony.

The premise: optimize for the maintainer

The usual objection to a strict test format is that it is verbose. A ten-line test becomes twenty-five. Section comments, prefixed variable names and an extra variable for every expected value all look like overhead.

They are overhead for the writer, once. For everyone who reads the test afterwards, they are signposts. A section header you can glance past costs nothing. An ambiguous test that you have to decode line by line, with the production code open beside it, costs a lot, and it costs that again for every person who reads it. Sooner or later a maintainer decides the cost is not worth paying, and the test is gone.

That trade, a few more lines for the writer in exchange for a test anyone can read in one pass and update with confidence, drives everything below.

1. Every test has the same shape

The root is the familiar Arrange, Act, Assert. AAA is fine for a three-line test of a pure function, and the guide allows exactly that. The trouble is that AAA is coarse. Once a test has mocks, captured values, an environment to build and interactions to verify, "Arrange" becomes a wall of code in which inputs, scaffolding and collaborators are mixed together.

So the guide splits each stage into named sections, always in this order, each introduced by a comment:

Stage Section What goes there
Arrange GIVEN The inputs and conditions that drive the scenario
CAPTURE Placeholders for values that a mock or fake will record
MOCKING Mocks and fakes, and how they behave
SETUP The environment the code under test runs in
SYSTEM UNDER TEST Construction of the thing being tested
Act WHEN The single action under test
Assert EXPECTATIONS What the results should be, declared before any assertion
THEN Assertions on results
LOGGING Assertions on what was logged
BEHAVIOR Verification of interactions with mocks

Empty sections are omitted, and sections are never merged. Section headers are full sentences that state intent rather than mechanics, such as // GIVEN: a customer whose balance is below the threshold. A long section is split into sub-sections with headers of their own, such as // MOCKING: the accounting API rejects the record.

Here is what that looks like for a service that should schedule a shipment when an order is submitted:

/// <summary>
/// Verifies that OrderProcessor publishes a shipment when an order flagged for immediate shipping is submitted.
/// </summary>
[Fact]
public async Task Test_SubmitOrder_SchedulesShipment_WhenShipImmediatelyIsSet()
{
    // GIVEN: an order that should be shipped immediately
    Guid givenOrderId = Guid.NewGuid();
    Order givenOrder = new(givenOrderId, ShipImmediately: true);

    // CAPTURE: the shipment message sent to the message bus
    ShipmentScheduled? capturePublishedShipment = null;

    // MOCKING: the repository holds the given order, and the bus accepts one shipment
    Mock<IOrderRepository> mockRepository = new(MockBehavior.Strict);
    mockRepository.Setup(repo => repo.GetAsync(givenOrderId)).ReturnsAsync(givenOrder).Verifiable();
    Mock<IMessageBus> mockMessageBus = new(MockBehavior.Strict);
    mockMessageBus
        .Setup(bus => bus.PublishAsync(It.IsAny<ShipmentScheduled>()))
        .Callback<ShipmentScheduled>(message => capturePublishedShipment = message)
        .Returns(Task.CompletedTask)
        .Verifiable();

    // SETUP: expose the repository and the message bus to the processor
    IOrderRepository envRepository = mockRepository.Object;
    IMessageBus envMessageBus = mockMessageBus.Object;

    // SYSTEM UNDER TEST
    OrderProcessor sut = new(envRepository, envMessageBus);

    // WHEN
    await sut.SubmitOrderAsync(givenOrderId);
    ShipmentScheduled? actualShipment = capturePublishedShipment;

    // EXPECTATIONS
    Guid expectedOrderId = givenOrderId;

    // THEN
    Assert.NotNull(actualShipment);
    Assert.Equal(expectedOrderId, actualShipment!.OrderId);

    // BEHAVIOR
    mockRepository.Verify();
    mockMessageBus.Verify();
}

It is long. It is also a test I can review in one pass without running it, because every block announces its purpose and every block is where I expect it to be. When a hundred tests share that shape, I stop reading the structure and only read the differences.

2. A variable's name says what role it plays

The second idea is that a reader should know, from a variable's name alone, which section it came from and what it is allowed to be used for:

Prefix Meaning
given* An input to the scenario
capture* A placeholder that a mock or fake fills in
mock* A mock object, used only in SETUP and BEHAVIOR
fake* A hand-written test double
env* A dependency as the code under test sees it
sut The thing being tested
actual* Something the test observed
expected* Something the test predicted
tmp* A staging value that never leaves its section

The payoff is in the assertions. THEN compares expected* values with actual* values, and nothing else. If an assertion compares against a given* variable, or reads a property straight off an env* collaborator, it is visibly wrong, and a reviewer catches it without understanding the domain.

The env* layer looks like the most pedantic rule in the guide, and the guide restates it in a chapter of its own. Mocks are first assigned to a variable of the interface type, and only that variable reaches the code under test:

IOrderRepository envRepository = mockRepository.Object;

Without it, constructor calls turn into rows of .Object accessors that the reader has to map back to their declarations. With it, the SYSTEM UNDER TEST section reads like production wiring, in domain types, and the mocking framework stays inside MOCKING and BEHAVIOR where it belongs.

The same thinking applies to EXPECTATIONS. Even when the expected value is just a copy of a given value, it gets its own expected* variable. That looks redundant until the day the expectation stops being a copy, and then the test already has the place to say so.

3. One test, one branch, and prove you reached it

Test names describe a specific branch of execution: Test_ProcessPayment_ReturnsDeclined_WhenBalanceIsLow, never Test_Process or Test_Works. One test covers one branch. Parameterized tests are allowed when the same behavior really is being exercised across several datasets, not as a way to cram different scenarios into one method.

Naming the branch is not enough, though. The failure mode I care about most is a test that passes because its setup never reached the branch it claims to test. The happy path ran, the assertion happened to hold, and the edge case is uncovered while the suite reports green.

This is also why line coverage across a project says so little. A line counts as covered if any test happened to run it, whether or not that test checked the result. Coverage that means something is measured per branch, and it only counts branches a test exercises on purpose, because only those tests actually verify that the outcome was the one intended.

So each test has to show that it took the path it is named for. In C# the practical tools are logging and strict mocks:

  • Assert on logs. Code that logs the branch it takes can be checked in a LOGGING section, and that is usually easier to write and read than reconstructing the path from mock calls.
  • All mocks are strict or verifiable, and every setup is verified. An unexpected call fails the test. An expected call that did not happen fails the test. A mock that was set up and never verified is treated as a bug in the test.

Without TDD, this is what stands in for watching a test fail first. A test that proves it took its branch, and compares that branch's outcome with a declared expectation, has very little room to pass by accident.

Each test also carries a documentation comment in the form "Verifies that <SUT> <behavior> when <trigger>", so the prose maps directly onto the SYSTEM UNDER TEST, WHEN and THEN sections.

4. Mock as little as possible, and prefer fakes when mocks get gnarly

The mocking rules are short:

  • Never mock what you don't have to. Prefer real instances and shared fixtures.
  • Only mock what the code under test uses directly. Its dependencies have their own tests.
  • When a mock needs nested argument matchers, reflection or callback gymnastics to verify one interaction, write a fake.

The guide's example is a structured log line. Verifying it through a mocking framework takes a block of It.Is<It.IsAnyType>(...) matchers and reflection over anonymous types. A hand-written FakeLogger that records entries and exposes AssertExercised("Foo", "bar") turns the same check into one line that reads like the requirement. Fakes cost maintenance and can drift from the real thing, and the guide says so, but for verification-heavy collaborators they are usually the clearer choice.

Test data comes from a shared fixture factory: small functions such as Fixtures.Order() that each return a valid object, which a test then adjusts in its GIVEN section. When a model changes, the fixtures change in one place. Fixtures and fakes are code that every test relies on, so they get tests of their own.

Test the seams

Mocking has a blind spot, and it sits exactly where two components meet. In the caller's unit tests, the callee is mocked out by hand. In the callee's unit tests, its inputs are set up by hand. Both suites can be green while the caller calls the callee wrongly, because nothing checks that the caller's idea of the callee matches the real one, for every branch.

So the seams between components need tests of their own. This matters most in untyped languages, and with general-purpose mocking frameworks that do not enforce that a mock matches the call signature of the thing it replaces. There, a mock can accept a call the real component would reject, and the unit tests on both sides will never notice.

5. Copy-paste beats clever helpers

This one surprises people. The guide prefers duplicated setup over helper methods.

This is not the copy-paste I complained about at the start. Copying undisciplined code spreads the mess. What the guide copies is boilerplate: the same sections, in the same order, with the same setup, instead of poor shared code that tries to do the job for every test at once. A test that looks exactly like its neighbours is boring, and boring is the point. When everything that is the same looks the same, the actual differences stand out.

Start with a happy-path test that spells out the whole arrangement. Copy it for each edge case, change only what differs, and say what differs in the section header: // GIVEN: customer balance is below the threshold. Two tests that are ninety percent identical, side by side, are easier to review than this:

// The kind of helper the guide asks you not to write
private async Task AssertPaymentAsync(decimal balance, PaymentStatus expectedStatus) { ... }

[Fact]
public Task Test_ProcessPayment_ReturnsDeclined_WhenBalanceIsLow()
    => AssertPaymentAsync(2.00m, PaymentStatus.Declined);

[Fact]
public Task Test_ProcessPayment_ReturnsApproved_WhenBalanceIsSufficient()
    => AssertPaymentAsync(25.00m, PaymentStatus.Approved);

Both tests are short, and neither shows what it does. To find out what they arrange, which mocks they configure and what they assert, you have to open the helper and work out what 2.00m stands for. Every test that uses the helper depends on it, and changes to it ripple through all of them.

Shared fixtures don't contradict this. A fixture builds a piece of test data and nothing else. What the guide avoids is shared code that hides the sections of a test, so that the reader can no longer see what was arranged, what was done and what was checked.

With structured sections and a decent IDE, duplication is cheap to write and cheap to change. Refactor into a helper only when the helper is easier to understand than the code it replaces. For the same reason, shared [SetUp] methods are avoided: they create hidden coupling between tests.

6. Tests are deterministic, except where they shouldn't care

The same inputs must produce the same results on every run. Anything nondeterministic, such as the current time, random values, generated IDs or static singletons, is put behind an interface and injected, so a test can freeze it. When code mixes pure calculation with reads from the outside world, it is split into a method that gathers inputs and a method that computes, and the pure one is tested directly.

There is one deliberate nuance. Sometimes it is useful to allow nondeterminism in something that should not affect the outcome. The shipment test above does this with its order id: any id should work, so it gets a new one on every run. If a test like that flakes, the flake is information: it exposes hidden coupling or a misunderstood contract. Fix the bug it reveals or tighten the contract, but don't just pin the value and move on.

7. Tests mirror production

Finding a test should never require a search. Test namespaces mirror production namespaces with a .Tests suffix, and folders mirror namespaces. A test class is named after what it covers: FileHandlerTests for the class, FileHandlerHashingTests for one region of it (a #region in C#), and FileHandlerOpenTests for one method. The file name matches the class name.

Tests should also be able to reach what they test without tricks. Where production code would normally reach for private methods, it uses internal ones instead, and the production project exposes its internals to the test assembly (with InternalsVisibleTo, in C#). A private method has no real value that an internal one lacks, and the difference matters only to tests: they can be allowed to call internals directly, while private methods can only be reached through reflection and similar trickery.

8. Breaking the rules

The guide ends with a chapter on when to break it. Deviation is fine when it improves clarity or determinism for a case the standard shape can't express cleanly, as long as the reason is written down next to the code and revisited later. Most shortcuts trade a line saved today for ambiguity later: asserting on a given* value directly, skipping EXPECTATIONS because the expected value equals the input, or passing mock.Object straight into the constructor. Each saves a moment for the writer and costs one for every reader after.

What to take from this

What I would carry into any codebase, in any language:

  1. Optimize tests for the maintainer, not the writer.
  2. Give every test the same shape, with labelled sections in a fixed order, so reviewers read differences instead of structure.
  3. Name variables by role, so a wrong assertion looks wrong.
  4. Test one branch per test and prove the branch was taken, through logs, strict mocks or whatever your platform offers.
  5. Prefer real objects and fixtures to mocks, and fakes to gnarly mocks. Test your fixtures and fakes, and test the seams that mocks hide.
  6. Duplicate setup before you abstract it.
  7. Inject anything nondeterministic, and treat a flake in something that shouldn't matter as a finding.
  8. Make tests easy to find by mirroring production structure, and easy to reach by making internals visible to them.
  9. Break the rules only on purpose, and say why in the code.

David Brandt is a computer scientist, systems architect and independent scholar based in Reykjavík.