A Test Strategy for Go Services That Scale With the Team
Plan tests for a Go service as tiers with commands, budgets and owners: unit on save, integration on push, end to end after deploy. Timings from a sample.

A strategy is what you decide before the first test. For testing, it is a set of answers to boring questions: what kinds of tests do we write, how do we tell them apart, when does each run, how long may it take, and who looks after it. Teams without answers get the default: a pile of tests that grows until it is slow and flaky, and then a weekend of cleanup. Teams with answers can add a hundred services and keep the pipeline sane.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide describes a strategy for Go services that works from five people to fifty, with the mechanics shown on Go 1.25.6 and the numbers from a sample package. It builds on the test pyramid and on the tools in the Go testing guide.
The strategy is a table
Write it as a table in the repository, in TESTING.md or the contributor guide. Here is one for a typical service.
| Tier | Checks | Command | Runs | Budget |
|---|---|---|---|---|
| Unit | Logic in one package, fakes for I/O | go test ./... | On save, on every commit | 30 s total |
| Integration | Real database, queue, HTTP between components | go test -tags integration ./... | Before push, in CI on every pull request | 3 min |
| End to end | A few user flows against a running stack | go test -tags e2e ./e2e/... | After deploy to staging | 10 min |
| Fuzz and property | Parsers, encoders, money | go test -fuzz and -rapid.checks | Nightly | 30 min |
| Load | Latency and throughput on key endpoints | k6 script | Before release, weekly | 20 min |
The columns are the strategy. A row without a command is a wish. A row without a budget grows without limit. A row without a run schedule does not run. The table fits on a page and it is the first thing a new team member reads.
Mechanics: three commands
The tiers are separated by build tags, as described in the integration tag article. The sample package has a unit file, an integration file and an end to end file, each tagged. The costs are simulated with sleeps of 40 ms and 400 ms to stand for a database round trip and a browser step.
package main
import "time"
// Price is pure logic; Fetch stands for a call to a dependency.
func Price(qty, cents int) int { return qty * cents }
func Fetch(cost time.Duration) string { time.Sleep(cost); return "ok" }
func main() {}package main
import (
"fmt"
"testing"
)
func TestPriceUnit(t *testing.T) {
for i := range 200 {
t.Run(fmt.Sprint(i), func(t *testing.T) {
if Price(i, 3) != i*3 {
t.Fatal("bad")
}
})
}
}//go:build integration
package main
import (
"testing"
"time"
)
func TestFetchIntegration(t *testing.T) {
for range 10 {
Fetch(40 * time.Millisecond) // a database round trip, say
}
}//go:build e2e
package main
import (
"testing"
"time"
)
func TestJourneyE2E(t *testing.T) {
for range 3 {
Fetch(400 * time.Millisecond) // a browser step, say
}
}tier() { printf '%-26s' "$1"; shift; go test -count=1 "$@" . | sed -E 's/.*lab\s+/ /' | sed -E 's/^ok.*\t([0-9.]+s)$/\1/'; }
tier "unit"
tier "unit + integration" -tags integration
tier "all three" -tags 'integration e2e'unit 0.308s
unit + integration 0.633s
all three 1.834sThe unit tier ran 200 subtests of the pricing function in 0.252 seconds, most of it compilation and process start. With -tags integration, ten simulated database trips added about 0.4 seconds, for 0.649 in total. With both tags, three simulated browser steps added 1.2 seconds, for 1.860. The proportions are the point: the end to end file contains three tests and costs more than the other 210 combined.
That is also the mechanical argument for the tiers. A developer runs the first command on every save and gets an answer in a fraction of a second. The second is for before a push. The third is for the pipeline. Nobody has to remember which tests are slow, because the slowness is encoded in the tag.
Deciding which tier a test belongs in
New tests need a rule. One question works: what is the cheapest tier that can catch this bug?
- A wrong calculation, a bad branch, a parsing mistake: unit.
- A wrong SQL statement, a missing index behaviour, a transaction boundary, a serialisation mismatch with a real driver: integration, against the real database.
- Two services disagreeing about a response: a contract test, at unit cost.
- A page that does not render, a login that redirects wrongly across services: end to end, and only for flows that matter.
- A crash on odd input: a fuzz target.
- A slow endpoint: a load test or a benchmark.
When someone asks for an end to end test of a rule, ask whether the rule can be checked lower. Usually it can, and the lower test is faster and more precise when it fails.
Budgets and owners
A tier without a budget drifts. Set a number for each, measured in CI wall time, and publish it. When a tier passes its budget, the response is a standing agenda item, not an emergency: someone looks at the slowest tests, applies the methods in speeding up a slow suite, and either fixes the time or raises the budget with a reason.
Give each tier an owner, a person or a team who gets the alert when it is red on the main branch for more than a day. Flaky tests are a particular case: ownership means someone fixes or removes them, not that everyone reruns them. Track the clean-run rate of the main branch weekly. It is the cheapest honest measure of the suite's health.
What every service should have
A baseline that costs little and pays often:
- Table driven unit tests for all business logic, with injected clocks and IDs (see determinism).
- Handler tests with
httptestfor each route, including auth failures and bad input. - Repository tests against the real database in a container, one per query shape, with a transaction rollback per test.
- A contract test per consumer, if other teams depend on the API.
- The race detector on the whole unit tier in CI:
go test -race ./.... - A fuzz target for every parser of untrusted input.
- A smoke test after deploy: one request to a health endpoint and one real sign-in.
Seven items is a baseline, not a ceiling, and a small service may need fewer. The rule of thumb is that a bug found in production should add a test, in the lowest tier that would have caught it.
Adopting it on an existing service
Few teams start from nothing. The usual starting point is a repository with a few hundred tests of mixed kinds in one go test ./... that takes eight minutes and fails twice a week. Adopt the strategy in this order, each step shippable by itself.
- Measure. Run
go test -json ./...once and total the time by package. The slowest packages are almost always the ones that touch a database or start a server. Write the numbers down; they are the baseline. - Tag the slow tier. Add
//go:build integrationto the files in those packages that need external services, and add the CI job that runs with the tag. The default run drops to the fast tests, and nothing is lost because the tagged job still runs the rest. - Fix the flakes that block trust. List the tests that failed and passed on rerun in the last month, and fix or quarantine them with an owner and a date. The flaky tests guide gives the method.
- Write the table and put it in the repository. Use the numbers from step 1 as the first budgets, rounded up, and promise to tighten them.
- Add the missing baseline items one per week: the race detector in CI, a fuzz target for the worst parser, a contract test for the consumer that broke last quarter.
- Ratchet. Add coverage and clean-run-rate tracking, and tighten budgets as the suite gets faster.
By the end of a quarter the team has a faster default, an explicit slow tier, and a plan. None of it needed a rewrite.
Laying the pipeline over the tiers
The CI configuration mirrors the table. Four jobs cover most services.
- Fast:
go vet ./...,go test -race ./...and a linter. Runs on every push, fails the pull request. This is the unit tier, with the race detector on, and it should finish in a couple of minutes. Cache the Go build cache between runs; seego env GOCACHE. - Integration: starts the dependencies, runs
go test -tags integration -count=1 ./.... Runs on every pull request, in parallel with the fast job. The-count=1flag is there because the test cache cannot see the state of the database, as the cache article explains. - Post-deploy: after a deploy to staging, runs the end to end tier and a smoke check against the real environment. A failure blocks promotion to production.
- Scheduled: nightly fuzzing with a fixed time per target, a repeated run with
-count=20 -shuffle=onto find flakes, and weekly load tests.
Make the jobs independent. A failure in the integration job should not hide the result of the fast job, because the two tell you different things. Publish the test JSON from each job as an artefact, so the slowest-test report and the flake tracker read the same data.
Environments and data
Each tier needs its own idea of an environment. Unit tests need none. Integration tests need dependencies that start from a clean state in seconds: containers with pinned versions, a template database, fixtures built in code rather than a shared dump. End to end tests need a stack that resembles production, usually a staging environment, and test accounts that the tests create and delete themselves.
The failure mode to avoid is the shared, long-lived test environment that everyone writes to. State accumulates, tests depend on each other's leftovers, and a bug is blamed on "the environment". Create what the test needs and remove it afterwards, tag test data so a janitor job can clean what leaks, and never point a test at production data.
Policy for the awkward cases
Write down the rules you would otherwise argue about each time.
- A flaky test is quarantined within a day with an issue, an owner and a date, and deleted if not fixed in a month.
- A new bug fix includes a test that fails without the fix. A reviewer may ask to see it fail.
- A test over its tier's per-test limit is moved or optimised. Pick the limit, for example 100 ms for unit and 5 s for integration.
- Skipping needs a reason in the message, and skipped tests are counted in CI output.
- Mocks are for boundaries. A mock of your own package is a sign the seam is wrong.
- Generated code is excluded from coverage and from review of tests.
Short rules that fit in the document are followed. Long ones are not.
Anti-patterns that a strategy prevents
The retry-until-green pipeline. A CI job configured to rerun failed tests three times. The pass rate looks fine, and real bugs hide in the retries. Retry the infrastructure, not the assertions.
The test suite as a second implementation. Tests that restate the code's logic line by line, so any change in the code requires the same change in the test. They find nothing and cost every refactor.
One huge end to end suite. Written because it feels like the real thing, it grows to an hour and fails daily. The model in the pyramid article shows why.
Coverage as a target. People write assertion-free tests to move a number; the mutation article shows a suite at 100% line coverage that missed half the changes it was tested against.
No owner. A test suite that belongs to everyone is looked after by no one. Name an owner per tier.
Coverage, mutation and other numbers
Use numbers to find problems, not to grade people. Line coverage per package shows code no test touches, as the coverage article explains, and a floor with a ratchet prevents decay. Mutation testing, run on a few critical packages, tells you whether the assertions are real, as in the mutation article. Do not set a repository-wide target of 80% and hold people to it; set a floor, watch the trend, and look at the files below it.
Scaling with the team
Strategy exists so that new people do the right thing without being told. Three practices carry it.
- Make the right thing the easy thing. A
make test,make test-integration,make test-alltrio, test helpers in a shared package, builders and fakes ready to import. If writing a table test takes two minutes, people write it. - Review tests like code, and ask the same questions of each: what does this prove, what would make it fail, which tier is it in?
- Prune. Delete tests that duplicate a lower tier, that test the framework, or that nobody can explain. A smaller suite that people trust is better than a larger one they rerun.
As the team grows, split ownership by package or service, not by tier, so the people who change the code also look after its tests. Platform or developer-experience staff own the shared tooling and the pipeline, and the budgets.
When the strategy needs to change
Revisit the table when: a tier passes its budget twice; a class of production bug escapes all tiers; the architecture changes (a monolith becomes services, so contract tests appear); or the clean-run rate drops below the level the team accepts. Write the change in the table, with the date and the reason, so the history is a record of what you learned.
A strategy is not the tools. The same table works with testify, with the standard library only, or with a BDD layer such as godog. The commands and flags are the ones in the go command's testing documentation, and the build constraint syntax is in the build constraints reference.


