When Good Unit Tests Go Bad
Or: Why Your Green CI Pipeline Might Be Lying to You
Let me paint you a picture. It’s a scene so familiar you could probably sketch it from memory.
Your team has just finished building a new endpoint — let’s call it POST /orders. Following every best practice in the book, you've written unit tests for the controller (with mocks), unit tests for the service (more mocks), and unit tests for the repository (even more mocks). CI is green. Code coverage sits at a comfortable 90%. Everyone pats themselves on the back. This is textbook software engineering.
If you’re nodding along thinking “yes, this is basically what we do,” then good. Stay with me. Because I’m about to show you why this perfectly reasonable approach might be setting you up for a very unreasonable failure.
The Refactor That Broke Everything
A few months ago, we needed to make a simple change. Rename a route. Adjust an attribute. Move some validation logic. The kind of refactoring you do on a Tuesday afternoon without thinking twice.
Twenty unit tests broke.
Here’s the thing: the behaviour was still correct. Nothing customer-facing had changed. A real HTTP client hitting the endpoint would get exactly the same response they’d always gotten. But our test suite was painted red, and we spent the better part of an afternoon updating mocks, adjusting assertions, and generally wrestling with tests that were supposed to be helping us.
“The first rule of any technology used in a business is that automation applied to an efficient operation will magnify the efficiency. The second is that automation applied to an inefficient operation will magnify the inefficiency.” — Bill Gates
We had automated ourselves into a corner. Our tests weren’t coupled to what the code did — they were coupled to how it was shaped. And there’s a world of difference between those two things.
The Anatomy of a Useless Test
Let me show you something. This is a unit test you’ve probably written a hundred times:
[Test] public async Task PostToOrders_Returns200() { var controller = new OrdersController(_mockService.Object); var result = await controller.Post(new CreateOrderRequest()); Assert.That(result, Is.InstanceOf<OkResult>()); }Quick question: what does this test actually verify? Think about it for a moment.
If you said “the HTTP route works,” I have bad news. This test mocks the service, never hits real routing, never touches the framework’s model binding, and never sees the actual application pipeline. It’s testing a method call on a C# class — nothing more.
Want proof? Change the controller’s route attribute to [HttpPost("new-route")]. Run the test. It passes. Now hit /orders with a real HTTP client. 404.
The test didn’t test the route. It never did.
The Mock Multiplication Problem
It gets worse. Here’s what a real-world service test looks like when you’re following conventional wisdom:
// Arrange var mockRepository = new Mock<IOrderRepository>(); var mockEmailService = new Mock<IEmailService>(); var mockInventoryService = new Mock<IInventoryService>(); var mockPaymentService = new Mock<IPaymentService>(); var mockLogger = new Mock<ILogger<OrderService>>(); var mockValidator = new Mock<IOrderValidator>();Six mocks. For one test. And this is a relatively simple service.
Each test in the suite sets up all six mocks individually, configures them with the right return values, instantiates the service, and then — finally — gets around to testing something. Twenty lines of ceremony for three lines of actual verification.
Now imagine you need to add an audit service. One new dependency. Every single test breaks with a compilation error because the constructor signature changed. You spend an afternoon adding mockAuditService to tests that don't care about auditing and will never test auditing.
“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” — Antoine de Saint-Exupéry
We’ve achieved the opposite. We’ve created tests where there’s nothing but things to add — and none of it relates to the actual behaviour we care about.
The Real Cost: Refactoring Paralysis
Here’s what happens in practice. Because the tests are brittle, developers start to fear moving logic. Fear redesign. Fear refactoring. The architecture freezes in place — not because it’s good, but because changing it means rewriting tests that weren’t testing anything meaningful in the first place.
I’ve watched teams spend more time maintaining their mock setups than writing production code. I’ve seen PRs where the test changes outnumber the implementation changes ten to one. And I’ve seen developers quietly skip writing tests altogether because the ceremony isn’t worth the supposed safety.
“In preparing for battle I have always found that plans are useless, but planning is indispensable.” — Dwight D. Eisenhower
The same principle applies here. Unit tests aren’t useless — but the way we’ve been writing them often is. We’ve confused the activity of testing with the outcome of confidence.
A Better Way: Test the Behaviour, Not the Shape
What if instead of mocking everything and testing internal wiring, we just… tested the actual behaviour? The thing our customers experience?
In the .NET world, WebApplicationFactory lets you spin up your real application and hit it with real HTTP requests. No mocks. No constructor ceremony. Just: "When I send this request, I expect this response."
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client;
public OrdersApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace ONLY what you need — the database
services.RemoveAll<IOrderRepository>();
services.AddScoped<IOrderRepository, MockOrderRepository>();
});
}).CreateClient();
}
[Test]
public async Task CreateOrder_ReturnsCreated_WithValidRequest()
{
var request = new { CustomerName = "Alice", TotalAmount = 99.95 };
var response = await _client.PostAsJsonAsync("/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
Assert.Equal("Alice", order.CustomerName);
}
[Test]
public async Task CreateOrder_ReturnsBadRequest_WhenNameMissing()
{
var request = new { TotalAmount = 99.95 };
var response = await _client.PostAsJsonAsync("/orders", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}Notice what’s missing? Six mocks and twenty lines of setup. We swap out the repository for an in-memory version — because we don’t want to hit a real database in tests — and everything else is real. Real controllers, real services, real validation, real routing.
These black-box tests validate real routing, real serialization, real model binding, real error formatting, real DI wiring, and real startup configuration. They test the application the way a real client experiences it.
And here’s the magic: they survive refactoring. Change the internal structure however you like. Split that handler into three methods. Rename every private variable. As long as the HTTP contract stays the same, the tests stay green.
But What About the Testing Pyramid?
I can hear the objection already: “But the testing pyramid says we should have lots of unit tests and fewer integration tests!”
The testing pyramid made sense when it was created. One codebase. One runtime. No distributed complexity. No network latency. No JSON contracts. No microservices.
That’s not the world most of us live in anymore. In a distributed system, the boundaries between services are the most important things to test — and those boundaries are invisible to unit tests. A pyramid built for monoliths becomes actively misleading when applied to microservices. It encourages teams to write 500 controller unit tests that prove nothing while ignoring the cross-service flows that actually break in production.
“It is difficult to get a man to understand something when his salary depends upon his not understanding it.” — Upton Sinclair
We’ve built careers and tooling and entire philosophies around unit testing. Questioning it feels almost heretical. But the evidence is right there in our broken CI pipelines and our fear of refactoring.
The Path Forward
I’m not saying unit tests are bad. I’m saying unit-test absolutism is bad. Here’s a more balanced approach:
Test at the boundary. If your system is consumed through HTTP, test it through HTTP. Your customers don’t care about your internal class structure — why should your tests?
Reserve unit tests for pure logic. Complex calculations, business rules with many branches, algorithmic code — these genuinely benefit from isolated unit tests. But a controller that just delegates to a service? That’s not where the complexity lives.
Measure refactorability, not coverage. A test suite that makes you afraid to refactor is worse than no tests at all. It’s actively impeding your ability to improve the codebase.
Accept that the pyramid has evolved. In a microservices world, behavioural tests and contract tests matter more than isolated unit tests. The shape of your testing strategy should match the shape of your architecture.
The Bottom Line
“There is nothing so useless as doing efficiently that which should not be done at all.” — Peter Drucker
We’ve become remarkably efficient at writing tests that don’t test anything meaningful. We’ve optimised for the wrong metric — coverage instead of confidence, activity instead of outcome.
The next time you find yourself setting up six mocks for a simple test, ask yourself: what am I actually verifying here? If the answer is “that Moq returns what I told it to return,” maybe it’s time to try a different approach.
Test behaviours, not shapes. Test boundaries, not internals. And for the love of all that is holy, test it the way your customers will actually use it.
Now, if you’ll excuse me, I need to go delete about 200 lines of mock setup from our test suite. Our deployment pipeline should be about 30% faster by the time I’m done — and we might actually catch a bug or two.