Skip to main content
SEMastery
Testingintermediate

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.

13 min readUpdated October 9, 2025

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.

Figure 1: The classic test pyramid. Many fast unit tests at the bottom, fewer integration tests in the middle, very few slow end-to-end tests on top.

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

Write code
Mock all neighbours
Test passes
Real DB query wrong
Prod breaks

Steps

1

Write code

Controller calls service calls repo

2

Mock all neighbours

Service and repo are fakes

3

Test passes

Only checks fake call order

4

Real DB query wrong

No real database was used

5

Prod breaks

Green tests, red users

A heavily mocked unit test can pass while the real system is broken.

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.

Figure 2: The testing trophy. Static checks form the base, then unit tests, a fat layer of integration tests, and a few end-to-end tests on top.

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

New behaviour
Has real logic?
Crosses boundaries?
Critical journey?

Steps

1

New behaviour

Something must be verified

2

Has real logic?

If yes, a focused unit test

3

Crosses boundaries?

If yes, an integration test

4

Critical journey?

If yes, one end-to-end test

A simple rule of thumb I follow before writing any 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.

IdeaTest pyramidTesting trophy
Biggest layerUnit testsIntegration tests
Static checksNot shownThe base of everything
Attitude to mocksMock to isolate unitsAvoid mocking your own code
Best fitLibraries, rich domain logicWeb apps, APIs, glue-heavy code
Main riskOver-mocked, brittle testsSlower 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.

Figure 3: An integration test drives the real pipeline end to end, all the way to a real database in a container.

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 testWhy
Pure calculation, many branchesUnit testFast, precise, no I/O needed
A controller calling a service calling a DBIntegration testThe risk is in the wiring
A full user journey like checkoutOne end-to-end testConfidence for the whole path
A type or namespace ruleArchitecture testGuards 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 WebApplicationFactory and 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

Related Posts