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.
- 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.
[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.
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.
// 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.
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.