Back to the blog

Unit tests with mocks in C# — and where they stop helping

1 min read

Mocking libraries such as Moq make it trivial to test a class without touching a database or an HTTP endpoint. That convenience has an edge to it: a test suite made entirely of mocks verifies that your code calls the methods you told it to call, which is not the same as verifying that it works.

The useful case

Mocks earn their place at a boundary you do not own:

[Fact]
public async Task Order_is_rejected_when_payment_fails()
{
    var payments = new Mock<IPaymentGateway>();
    payments
        .Setup(p => p.ChargeAsync(It.IsAny<decimal>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync(PaymentResult.Declined);
 
    var service = new OrderService(payments.Object, new InMemoryOrderRepository());
    var result = await service.PlaceAsync(TestData.Order);
 
    Assert.False(result.Succeeded);
    Assert.Equal(OrderStatus.Rejected, result.Order.Status);
}

The payment gateway is external, slow and has side effects. Replacing it is exactly right.

Where it goes wrong

The failure mode looks like this:

// This test passes whether or not the discount is actually applied.
var calculator = new Mock<IDiscountCalculator>();
calculator.Setup(c => c.Apply(It.IsAny<decimal>())).Returns(90m);
 
var total = new Cart(calculator.Object).Total();
 
calculator.Verify(c => c.Apply(100m), Times.Once);

This asserts that one method was called with one argument. It says nothing about whether the total is right. If Cart returns the wrong number, the test still passes.

Rule of thumb: mock what you cannot control — clock, network, file system, third-party APIs. Do not mock your own value objects and pure calculations. Those you can just call.

Coverage lies, mutation testing does not

A suite of mock-heavy tests can reach 90% line coverage and still miss real bugs. The honest check is mutation testing — Stryker.NET deliberately changes your code and reports which mutations your tests failed to catch:

dotnet tool install -g dotnet-stryker
dotnet stryker

The first run is usually uncomfortable. That discomfort is the point.