Skip to main content
ANVISoftware Solutions
Lesson 11 of 13Advanced16 min

Organising a Test Suite

By the end of this lesson

Name and group tests so failures are easy to interpret.

A suite of eight hundred tests has an interface, and the interface is the list of names that appear when something fails.

That list is usually all anyone reads. Nobody opens a test file because the build went red — they read the failing names, form a hypothesis, and only then open a file. If the names do not support that, every failure costs ten minutes before the work starts.

This lesson is about two things that decide whether the list is useful: what each test is called, and whether tests can influence each other. The second matters because a test that fails for something another test did produces a name that is actively misleading.

Two failure reports for the same broken code
C#
// A suite named after methods
Failed OrderServiceTests.SubmitTest
Failed OrderServiceTests.SubmitTest2
Failed OrderServiceTests.TestTotals

// The same suite, named after scenarios and expectations
Failed Submit_WhenOrderIsAlreadySubmitted_Throws
Failed Submit_WhenOrderHasNoLines_Throws
Failed CreateOrder_WithThreeItemsAtTwentyFive_SetsTotalToSeventyFive
  • The first report says something is wrong with submitting orders. You know the area and nothing else, so the next step is opening three files.
  • The second report is a diagnosis. Two rejection rules and one calculation have broken together, which points at a shared change — probably to how an order's lines are read — and you can form that hypothesis without opening anything.
  • The pattern is method, scenario, expectation. Underscores separate the three parts because they make the boundaries visible in a long name; a team that prefers a different separator loses nothing as long as all three parts are there.
  • Long names are correct here. A test name is read far more often than it is typed, and it is never called from other code, so the usual pressure towards short identifiers does not apply.
  • SubmitTest2 deserves a specific mention. A number in a test name means somebody needed a second scenario and did not say what it was. Whatever distinguishes it from the first is the name.

Structure that keeps a growing suite navigable:

  • One test project per production project, named after it: Ordering.Domain and Ordering.Domain.Tests. Anyone can find the tests for code they are changing without asking.
  • Folders inside the test project that mirror the folders in the production project. A test for Services/OrderService.cs lives in Services/OrderServiceTests.cs.
  • One test class per class under test, as the default. Split into nested classes per method when a class grows past a screenful — SubmitTests and CreateOrderTests inside OrderServiceTests keeps the grouping visible in the report.
  • Builders and fakes in a shared Builders or Fakes folder, not nested inside the first test class that needed them. The second test class that needs them should not have to copy anything.
  • Separate projects for the slow tiers: Ordering.IntegrationTests and Ordering.E2ETests. Separate projects are easier to run selectively than filters, and the pipeline can put them in different jobs.
  • Traits on tests that need different treatment — [Trait("Category", "Slow")] — so a local run can exclude them with --filter Category!=Slow.

Shared setup couples tests without saying so, and the instinct behind it is the same instinct that serves you well in production code. Removing duplication from tests is the main source of unexplained failures in test suites.

The reason is that a test is a self-contained description of one scenario. When setup moves out of it and into something shared, the description becomes incomplete, and worse, mutable — two tests can now change the same object, and the second one to run sees the first one's changes.

xUnit helps here by default and it is worth knowing exactly how. It creates a new instance of the test class for every test, so instance fields and the constructor are per-test setup and cannot leak. Static fields, IClassFixture and collection fixtures are the deliberate exceptions, and each one is a decision to share.

Three ways to arrange, in increasing order of coupling
C#
public class OrderServiceTests
{
    // 1. Per-test setup. A new instance of this class is created for every
    //    test, so nothing here can leak between them.
    private readonly InMemoryOrderRepository _repository = new();
    private readonly OrderService _service;

    public OrderServiceTests() => _service = new OrderService(_repository);

    // 2. Shared, and safe, because it is immutable and derived per call.
    private static Order ADraftOrder() => new OrderBuilder().Build();

    // 3. Shared and mutable — the problem case.
    private static readonly Order TheOrder = new OrderBuilder().Build();

    [Fact]
    public void Submit_WhenOrderIsDraft_SetsStatusToSubmitted()
    {
        _service.Submit(TheOrder);                       // mutates TheOrder
        Assert.Equal(OrderStatus.Submitted, TheOrder.Status);
    }

    [Fact]
    public void AddDiscount_OnADraftOrder_ReducesTheTotal()
    {
        _service.AddDiscount(TheOrder, 10);              // may now be submitted
        Assert.Equal(180m, TheOrder.Total);
    }
}
  • The fields in section 1 look shared and are not. xUnit constructs the class again for each test, so each test gets its own repository and service. This is the cheapest correct way to remove setup duplication in xUnit, and it is the one to reach for first.
  • The helper method in section 2 is safe because it returns a new order on every call. Sharing a factory is fine; sharing an instance is not.
  • The static field in section 3 is the failure. Both tests act on the same order, so the result depends on which ran first — and xUnit gives no ordering guarantee, which means the outcome can differ between machines and between runs.
  • The failure this produces is the worst kind to debug: each test passes when run alone. Running a single test to investigate makes the symptom disappear, which sends people looking for a problem in the runner.
  • If you need genuinely expensive shared setup, such as a database container, use IClassFixture and share only things that are read, never written. Anything a test mutates belongs to that test.

The sharing mechanisms in xUnit, and what each one is for:

Constructor and instance fields
Per-test setup. Runs again for every test. The default, and the right choice for almost everything.
IDisposable / IAsyncDisposable on the test class
Per-test teardown, for anything the test opened. Runs even when the test fails, which a line at the end of the test body does not.
IClassFixture<T>
One instance shared by every test in the class, created before the first and disposed after the last. Use it for expensive, read-only setup such as a started container.
ICollectionFixture<T>
The same, shared across several classes that opt into the collection. It also stops those classes running in parallel with each other, which is sometimes the actual reason to use it.
Static fields
Shared for the lifetime of the process and invisible at the point of use. Acceptable for a constant; a reliable source of order-dependent failures for anything else.

Summary

  • The failing-name list is the interface to a suite, so names carry method, scenario and expectation
  • Mirror the production project's structure, and keep slow tiers in their own projects
  • In xUnit the constructor and instance fields are per-test, which is the safe way to remove setup duplication
  • Static fields and fixtures are deliberate sharing; anything a test mutates belongs to that test
  • A test that passes alone and fails in the suite is reporting shared state, not a runner problem

Practice

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

Try it yourself

Rename a failure report

Rewrite these four names so the report alone identifies the scenario and the expectation:

1. CalculatorTest

2. TestDiscountThrows

3. Submit_Works

4. Order_Total_Test_2

Show solution

1. ApplyDiscount_WithTwentyPercentOffOneHundred_ReturnsEighty. The original names the class; the replacement names one case.

2. ApplyDiscount_WithPercentageAboveOneHundred_ThrowsArgumentOutOfRange. "Throws" alone leaves both the trigger and the exception type unstated, and a guard that throws the wrong type would still pass.

3. Submit_WhenOrderIsDraft_SetsStatusToSubmitted. "Works" is the word that appears whenever the author has not decided what the test is responsible for.

4. The 2 means a second case exists. Find it and name it — CreateOrder_WithZeroQuantity_Throws, or whatever it actually covers. If you cannot tell from reading the body what distinguishes it, that is a strong hint the test itself needs narrowing.

One check worth applying to any name: could a colleague tell from the name alone whether a failure is a bug in the code or an out-of-date expectation? If not, the name is missing either the scenario or the expectation.

Think about it

Passes alone, fails in the suite

A test passes when you run it on its own and fails when the whole class runs. Nothing in the test touches a database, a file or the network.

What are the likely causes, and how would you confirm which one it is?

Show solution

The overwhelmingly likely cause is state shared between tests: a static field, a static collection, a cached singleton, or a fixture object that one test mutates. The test is reading something another test changed.

Second possibility: something global that does not look like state — a culture or time zone set by another test, a registered handler that was never removed, an environment variable. These behave identically to shared state and are harder to spot because they are not fields.

Third: parallel execution. xUnit runs test classes in parallel by default, so two classes touching the same resource can interfere even with no shared field between them.

How to confirm: run the class with parallelisation disabled. If it passes, the cause is concurrency. If it still fails, it is ordering, so run the failing test immediately after each of its siblings until you find the one that breaks it — that pair identifies the shared state.

The fix is almost never a retry or an ordering attribute. Move the state into the test that needs it, so nothing outside a test can affect its result.

Saved in this browser only.