A large test suite isn't the same thing as a good one. We've inherited codebases with thousands of passing tests and a production incident the same week, from a bug none of those tests could have caught. Here's what actually makes a .NET test suite worth trusting.

Key takeaways
  • A test should fail when the behavior it describes breaks — and pass for every other change. If it does neither reliably, it's not pulling its weight.
  • Mock the boundaries you don't own (HTTP, the database, the clock) — not every collaborator. Over-mocking tests the mock, not the code.
  • Test data builders beat copy-pasted setup blocks — they make the one relevant difference in each test obvious.
  • Coverage percentage is a lagging indicator. A codebase can hit 90% coverage and still ship the same bug three times.

Name tests for the failure message

A failing test's name is often the only context you get at 2am. MethodName_Scenario_ExpectedResult means the test name alone tells you what broke, without opening the file.

csharp
[Fact]
public void Submit_WhenOrderHasNoLines_ThrowsInvalidOperationException()
{
    var order = new Order(Guid.NewGuid(), CustomerId.New());

    var act = () => order.Submit();

    act.Should().Throw<InvalidOperationException>();
}

Test data builders instead of giant setup blocks

Copy-pasting a twenty-line object graph into every test and tweaking one field buries the one thing that actually matters to that test. A builder makes the relevant difference the only thing visible.

csharp
public class OrderBuilder
{
    private OrderStatus _status = OrderStatus.Draft;
    private readonly List<OrderLine> _lines = new() { new OrderLine(Sku.Of("WIDGET-1"), 1) };

    public OrderBuilder WithStatus(OrderStatus status) { _status = status; return this; }
    public OrderBuilder WithNoLines() { _lines.Clear(); return this; }

    public Order Build()
    {
        var order = new Order(Guid.NewGuid(), CustomerId.New());
        foreach (var line in _lines) order.AddLine(line.Sku, line.Quantity);
        if (_status == OrderStatus.Submitted) order.Submit();
        return order;
    }
}

// The test now reads as "what's different here", not "here's an entire order"
var order = new OrderBuilder().WithNoLines().Build();

What actually deserves a mock

Mock the things you don't own and can't control in a test: the HTTP client hitting a third-party API, the database, the system clock, the message queue. Don't mock your own domain objects or simple value types — construct real ones. A test built entirely from mocks proves your code calls the mocks correctly, which is a much weaker claim than proving it behaves correctly.

csharp
// Over-mocked: this only proves SubmitOrderHandler calls two methods.
// It says nothing about whether the order ends up in the right state.
var mockOrder = new Mock<IOrder>();
mockOrder.Setup(o => o.Submit());
handler.Handle(command);
mockOrder.Verify(o => o.Submit(), Times.Once);

// Better: mock only the real boundary (the repository), use a real Order
var repository = new FakeOrderRepository(new OrderBuilder().Build());
var handler = new SubmitOrderHandler(repository, new FakeUnitOfWork());

await handler.Handle(new SubmitOrderCommand(orderId), CancellationToken.None);

var saved = await repository.GetAsync(orderId, default);
saved.Status.Should().Be(OrderStatus.Submitted);

Test behavior, not implementation

A test that asserts "method X called method Y" breaks the moment you refactor X's internals, even if the observable behavior didn't change. A test that asserts "given this input, the order ends up submitted" survives refactors and only breaks when the actual behavior changes — which is exactly the failure mode you want a test to catch.

Worth tryingIf you want an honest answer to whether your tests are actually catching bugs, or just running, try mutation testing. Stryker.NET deliberately introduces small bugs into your code and checks whether your test suite notices. A green suite against a mutated build is a much stronger signal than a coverage percentage.

A note on coverage targets

Coverage tells you code ran during a test, not that the test verified anything meaningful. We've seen 100%-covered methods with an assertion-free test that just calls the method and checks it didn't throw. Use coverage to find code with zero tests — genuinely useful signal — but stop treating a percentage target as a proxy for quality once you're past that.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.