The Test Pyramid Is a Lie (And What I Do Instead) in .NET
The test pyramid says write mostly unit tests. In real .NET apps that often backfires. Here is the testing trophy and how I balance tests instead.
The tiffin box and the food critic
Imagine your mother packs your school tiffin every morning. She wants to be sure the food is good before you carry it away. How can she check?
One way is to taste a single grain of rice, then one piece of potato on its own, then a tiny bit of plain salt, each by itself. If every single item is fine alone, she decides the meal is fine. This is fast. But it misses the real question. Is the sabzi too salty when you actually eat the rice and potato together? Tasting lonely ingredients does not tell you.
A second way is to take one normal spoonful, exactly the way you will eat it, rice and potato and a little gravy together, and taste that. One honest bite tells her far more than ten lonely ingredient checks. She is testing the meal the way it is really used.
The first way is like a strict unit test: check one tiny piece in total isolation. The second way is like an integration test: check several pieces working together, the way the user meets them.
For years we were told to mostly do the first thing. Taste the grains alone. Build a tall tower of tiny isolated checks. That advice is the famous test pyramid, and in a lot of real .NET projects it quietly leads us astray. This post explains why, and shows the balance I reach for instead.
What the test pyramid actually says
The test pyramid is an old, sensible picture. Mike Cohn drew it, and Martin Fowler made it well known. It has three layers.
The message is simple and mostly wise. Slow tests that drive a real browser and a real database are precious but painful. They break for silly reasons and take ages to run. So keep those few. Fast unit tests are cheap and quick, so have lots of them. The wider the base, the more checks you run on every build without waiting forever.
If you stop there, the pyramid is good advice. The trouble starts with how people use it.
Where the pyramid goes wrong in real .NET apps
The pyramid is a shape, not a law. But teams turned it into a law. Someone said "the base must be widest," and so people felt they had to write a huge number of unit tests, no matter what the code looked like. That created two real problems.
Problem one: testing glue code in isolation teaches you nothing. A lot of a normal web app is plumbing. A controller takes a request, calls a service, the service calls the database, and a result comes back. There is barely any logic. To unit test that controller "in isolation," you mock the service. To unit test the service, you mock the repository. Now your test only checks that you called a fake in the order you told it to call the fake. If the real database query is wrong, your green test will not notice.
Problem two: mocks freeze your mistakes. When you mock everything, your test and your code agree on a wrong assumption together. They both believe the repository returns a non-null user. Reality returns null, and production crashes while every unit test stays green. The test is married to the implementation, not the behaviour. Change how the code is built, and dozens of tests break even though the app still works perfectly.
How over-mocking hides bugs
Steps
Write code
Controller calls service calls repo
Mock all neighbours
Service and repo are fakes
Test passes
Only checks fake call order
Real DB query wrong
No real database was used
Prod breaks
Green tests, red users
So the lie is not the pyramid itself. The lie is "more unit tests always means more safety." For thin, glue-heavy .NET code, a tower of mocked unit tests can give you a warm feeling and almost no real protection.
A quick taste of the problem in code
Here is the kind of service that often gets a useless unit test. It mostly moves data around.
public class OrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailSender _email;
public OrderService(IOrderRepository repo, IEmailSender email)
{
_repo = repo;
_email = email;
}
public async Task<int> PlaceOrderAsync(Order order)
{
var id = await _repo.AddAsync(order);
await _email.SendAsync(order.CustomerEmail, "Order placed");
return id;
}
}A typical "unit test" for this just sets up two mocks and checks they were called.
[Fact]
public async Task PlaceOrder_saves_and_emails()
{
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.AddAsync(It.IsAny<Order>())).ReturnsAsync(42);
var email = new Mock<IEmailSender>();
var service = new OrderService(repo.Object, email.Object);
var id = await service.PlaceOrderAsync(new Order { CustomerEmail = "[email protected]" });
Assert.Equal(42, id);
email.Verify(e => e.SendAsync("[email protected]", "Order placed"), Times.Once);
}Read it closely. This test never touches a real database. It never checks that the order is actually stored, that the email address column is long enough, or that a duplicate order is rejected. It only proves that the code calls the fakes you handed it. If you rename AddAsync or reorder the lines, the test breaks even though nothing real changed. That is effort spent guarding the shape of the code instead of its value.
The testing trophy: my preferred shape
The shape I actually aim for is Kent C. Dodds' testing trophy. It keeps all the same kinds of tests but changes the sizes, and it adds a base layer the pyramid forgot.
The four layers, from bottom to top:
- Static checks. The C# compiler, nullable reference types, and analyzers. These run constantly and catch whole classes of bugs before you even write a test. They are free, so use them fully.
- Unit tests. Now reserved for code with genuine logic, not glue. A discount calculator, a date range, a state machine, a parser.
- Integration tests. The widest, most valued layer. They run your real code against a real database and a real HTTP pipeline. They mirror how the app is actually used.
- End-to-end tests. A small number that drive the full system, browser and all, for your most important journeys like login and checkout.
The big change is that the middle bulges out. You write the most integration tests, because they catch the most real bugs per hour of effort.
How I decide what kind of test to write
Steps
New behaviour
Something must be verified
Has real logic?
If yes, a focused unit test
Crosses boundaries?
If yes, an integration test
Critical journey?
If yes, one end-to-end test
Pyramid vs trophy, side by side
It helps to see the two ideas next to each other. They are not enemies. They just place the weight differently.
| Idea | Test pyramid | Testing trophy |
|---|---|---|
| Biggest layer | Unit tests | Integration tests |
| Static checks | Not shown | The base of everything |
| Attitude to mocks | Mock to isolate units | Avoid mocking your own code |
| Best fit | Libraries, rich domain logic | Web apps, APIs, glue-heavy code |
| Main risk | Over-mocked, brittle tests | Slower suite if you are careless |
Neither shape is "the truth." If you are building a math-heavy library full of pure functions, the pyramid fits beautifully, because almost everything is real logic worth unit testing. If you are building a normal ASP.NET Core API that mostly shuttles data between HTTP and a database, the trophy fits better, because the real risk lives in the wiring, not in any single class.
What an integration test looks like in .NET
This is where modern .NET shines. The tools make the "big middle" cheap to write. WebApplicationFactory<T> boots your whole app in memory, and Testcontainers gives you a real throwaway database in Docker. So your test exercises the genuine HTTP pipeline, model binding, dependency injection, and SQL, all at once.
Here is a real integration test for the same order flow. Notice there are no mocks of your own code.
public class OrderApiTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public OrderApiTests(ApiFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task Posting_an_order_stores_it_and_returns_201()
{
var order = new { customerEmail = "[email protected]", total = 250 };
var response = await _client.PostAsJsonAsync("/orders", order);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// Now fetch it back through the real API to prove it was stored.
var location = response.Headers.Location!;
var fetched = await _client.GetFromJsonAsync<OrderDto>(location);
Assert.Equal("[email protected]", fetched!.CustomerEmail);
Assert.Equal(250, fetched.Total);
}
}This single test covers more real ground than a dozen mocked unit tests. It proves the route is wired, the JSON binds correctly, the order saves to a real database, the id comes back, and a follow-up GET returns the same data. If any layer in that chain is broken, the test goes red for an honest reason.
The ApiFactory is where the magic sits. It starts a real database container and points the app at it. Below is a trimmed version using Testcontainers and the IAsyncLifetime pattern that xUnit calls before any test runs.
public class ApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db =
new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
public async Task InitializeAsync() => await _db.StartAsync();
public new async Task DisposeAsync() => await _db.DisposeAsync();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Never hardcode the connection string. Testcontainers picks a random port.
builder.UseSetting("ConnectionStrings:Default", _db.GetConnectionString());
}
}The important detail is the comment. Testcontainers assigns a random port each run, so you must read the connection string from the container and pass it in with UseSetting. Hardcoding it is the most common beginner mistake.
How the request flows through an integration test
It helps to picture what really happens when that test runs. Nothing is faked except the outside world.
Every arrow there is real code. The only thing that is not "production" is that the database lives in a disposable Docker container instead of a cloud server. That closeness to reality is exactly why these tests catch the bugs that matter, like a wrong WHERE clause or a missing migration.
When I still reach for a unit test
I have not abandoned unit tests. I love them for the right job: a class that holds real, branchy logic and no I/O. Here the lonely-ingredient taste is perfect, because the ingredient really is the meal.
public static class DiscountCalculator
{
public static decimal Apply(decimal amount, int loyaltyYears)
{
if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
var rate = loyaltyYears switch
{
>= 10 => 0.20m,
>= 5 => 0.10m,
>= 1 => 0.05m,
_ => 0m
};
return amount - (amount * rate);
}
}This screams for unit tests. There are clear branches, an edge case at zero, and no database in sight. A handful of fast [Theory] cases pins down every rule and runs in milliseconds. That is the pyramid base doing exactly what it is good at. The rule I follow: unit test the brains, integration test the plumbing.
A simple rule table I keep on my desk
When I am unsure which test to write, I glance at this.
| The code is mostly... | Best test | Why |
|---|---|---|
| Pure calculation, many branches | Unit test | Fast, precise, no I/O needed |
| A controller calling a service calling a DB | Integration test | The risk is in the wiring |
| A full user journey like checkout | One end-to-end test | Confidence for the whole path |
| A type or namespace rule | Architecture test | Guards the shape, not behaviour |
The trophy does not throw away any row of that table. It just reminds me that, for a normal web app, the second row is where most of my tests should live.
A note on speed and trust
People worry that integration tests are slow. They are slower than unit tests, yes. But "slower" is not "slow." With WebApplicationFactory booting the app in memory and Testcontainers reusing a single database container across a test collection, a few hundred integration tests can finish in a minute or two. You also reset the database between tests so they stay independent.
The payoff is trust. When a green integration suite says checkout works, it means a real request hit a real database and came back correct. That is a far stronger promise than a wall of mocks agreeing with each other. I would rather have two hundred honest integration tests than two thousand tests of fake call order.
Quick recap
- The test pyramid is good advice that got turned into a strict law. The real lie is "more unit tests always means more safety."
- In glue-heavy .NET web apps, over-mocked unit tests often check fake call order, not real behaviour, and they break when you refactor.
- The testing trophy keeps every kind of test but bulges the middle: a fat layer of integration tests gives the most confidence per hour.
- Its hidden base is static checks: the compiler, nullable reference types, and analyzers catch bugs for free.
- Write unit tests for real logic (calculations, state machines) and integration tests for plumbing (controller to service to database).
- Modern .NET makes the big middle cheap with
WebApplicationFactoryand Testcontainers. Read the connection string from the container; never hardcode it. - Keep end-to-end tests few and reserved for your most important journeys.
- Neither shape is universal truth. Pick the shape that matches your code: pyramid for logic-heavy libraries, trophy for wiring-heavy web apps.
References and further reading
- Integration tests in ASP.NET Core — Microsoft Learn
- The Testing Trophy and Testing Classifications — Kent C. Dodds
- On the Diverse And Fantastical Shapes of Testing — Martin Fowler
- Long Live The Test Pyramid — Smashing Magazine
- Testcontainers Best Practices for .NET Integration Testing — Milan Jovanović
- ASP.NET Core — Testcontainers for .NET
Related Posts
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
Creating Data-Driven Tests With xUnit (.NET 10)
A friendly .NET 10 guide to data-driven tests in xUnit: Theory, InlineData, MemberData, ClassData, strongly typed TheoryData, and xUnit v3 tips.
Enforcing Software Architecture With Architecture Tests in .NET
Learn how to enforce software architecture in .NET using architecture tests with NetArchTest and ArchUnitNET, so your layers and rules stay safe over time.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
5 Architecture Tests You Should Add to Your .NET Projects
Five simple architecture tests for .NET using NetArchTest. Protect layers, naming, and dependencies with code that fails the build when rules break.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.