The Test Pyramid for Go Backends Applied in Practice
Apply the test pyramid to a Go backend: many fast unit tests, fewer integration tests, few end to end. A cost model shows why the inverted shape fails.

The test pyramid is a drawing with three layers. At the bottom, many small tests that check one unit of logic, quickly. In the middle, fewer tests that check components together. At the top, a handful that exercise the whole system as a user would. The drawing is a claim about cost: each layer up costs more per test and fails for more reasons, so you want fewer of them.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64); the figures come from a stated model, not from a measured system
The claim is easy to accept and easy to ignore. This article makes it concrete with a small cost model, then turns it into rules for a Go backend. The model is a model: its numbers are assumptions I chose and state, and the lab only does the arithmetic.
What each layer is, in Go
Unit tests call a function or a method in the same process, with fakes for anything slow. They run in microseconds to milliseconds, use no network, and are the table driven tests most of this series is about. Parallel subtests and in-memory fakes keep the whole tier under a few seconds.
Integration tests run several real components together: a repository against a real PostgreSQL in a container, a handler through the real router and middleware, a client against an httptest server. They take tens to hundreds of milliseconds each, need an external dependency, and are separated from the unit tier with a build tag or a package of their own.
End to end tests drive the deployed system through its public interface, the way a user does: HTTP calls against a running stack, a browser with chromedp. They take seconds, depend on the most moving parts, and fail for reasons that have nothing to do with the change under test.
The cost model
The lab models a suite as three tiers with a count, a cost per test and a flake rate: the chance that one test fails for no reason in one run. The chance of a clean run is the product of every test's chance of not flaking.
package main
import (
"fmt"
"time"
)
type tier struct {
name string
n int
cost time.Duration
flaky float64 // chance that one test fails for no reason in one run
}
func suite(ts []tier) {
var total time.Duration
pOK := 1.0
for _, t := range ts {
total += time.Duration(t.n) * t.cost
for range t.n {
pOK *= 1 - t.flaky
}
}
fmt.Printf(" total serial time %-8v chance of a clean run %.0f%%\n", total.Round(time.Second), pOK*100)
}
func main() {
fmt.Println("pyramid: 500 unit, 50 integration, 5 end to end")
suite([]tier{{"unit", 500, 2 * time.Millisecond, 0}, {"int", 50, 200 * time.Millisecond, 0.002}, {"e2e", 5, 20 * time.Second, 0.03}})
fmt.Println("ice cream cone: 50 unit, 50 integration, 100 end to end")
suite([]tier{{"unit", 50, 2 * time.Millisecond, 0}, {"int", 50, 200 * time.Millisecond, 0.002}, {"e2e", 100, 20 * time.Second, 0.03}})
}pyramid: 500 unit, 50 integration, 5 end to end
total serial time 1m51s chance of a clean run 78%
ice cream cone: 50 unit, 50 integration, 100 end to end
total serial time 33m30s chance of a clean run 4%The assumptions are mine and deliberately plain: unit tests cost 2 ms and never flake; integration tests cost 200 ms and flake 0.2% of the time; end to end tests cost 20 seconds and flake 3% of the time. Change them and the numbers change. The shape does not.
The pyramid: 500 unit, 50 integration and 5 end to end tests. Total serial time is 1 minute 51 seconds, and the chance of a clean run is 78%. Roughly one run in five fails for no reason. That is already a bad number, and nearly all of it comes from five end to end tests. Where time is concerned, the five tests cost 100 of the 111 seconds.
The ice cream cone: 50 unit, 50 integration and 100 end to end tests. Total serial time is 33 minutes 30 seconds, and the chance of a clean run is 4%. Run it twenty-five times and one run is clean. People stop trusting it long before that, and a failure no longer means anything.
Two lessons come out of the arithmetic, and neither depends on the exact assumptions. First, the top tier dominates both columns: a hundred 20-second tests cost more than everything else combined, and a 3% flake rate across a hundred tests makes a clean run vanishingly rare because the probabilities multiply. Second, you cannot fix the top tier by being careful, only by having fewer of them. Halving the flake rate of 100 end to end tests still leaves a 20% clean-run rate.
Turning it into rules for a Go backend
- Push every decision down to the lowest tier that can check it. A pricing rule is a unit test. A SQL query is an integration test against the real database. A full sign-up flow is end to end, and only the flow.
- Keep the top tier to the flows that earn money or trust: sign up, log in, pay. Five to fifteen tests, each long, each with a clear owner. Run them against staging after a deploy, not on every commit.
- Make the middle tier fast enough to run before a push. Share setup, roll back transactions, use templates for databases: see speeding up a slow suite.
- When an end to end test fails, write the lower test that would have caught it, then keep the end to end test only if the flow is still worth it.
- Track the clean-run rate of the main branch. It is the number the model computes, and in a real system you can measure it: the share of runs with no failure that a re-run clears.
- Treat flake rate as a budget. A test that flakes more than a fraction of a percent is a bug; fix it or remove it.
What the pyramid gets wrong
The picture is a heuristic. It is wrong in at least three cases, and a good team knows when.
Some systems are mostly integration. A thin service that mainly moves data between a queue and a database has little logic to unit test. Its risk is in the wiring, and a "unit test" with every dependency faked checks the fakes. For such services the useful shape is closer to a diamond, with the weight in the integration tier, which is cheap in Go with containers.
Unit tests with heavy mocking test the mocks. Many fast tests are not worth more than fewer slow ones when they assert that a function called another function. The tier is defined by what it checks, not by speed. See fakes, stubs and mocks.
Contract and property tests do not fit the layers. A consumer contract test catches API drift at unit cost, and a fuzz target finds crashes at unit cost. Both buy end to end confidence for less.
Alternatives to the pyramid exist: the testing trophy puts the weight on integration tests, and the honeycomb does the same for microservices. They share the pyramid's core, which is that cost and flakiness grow upward, and they disagree on how much weight the middle can carry. For a Go backend with cheap containers, the middle can carry a good deal.
How to use this
Count your tests by tier, find your total time and your clean-run rate, and draw your own shape. If the top tier dominates time and failures, the fix is to move checks down, not to speed up the top. The testing strategy article shows how to plan the tiers for a service, and Martin Fowler's description of the test pyramid gives the history, and the Go documentation on testing covers the tooling. For the Go test tooling used in each tier, start at the testing guide.


