Skip to content
gohacks.dev
Testing

Speed Up a Slow Go Test Suite Without Losing Coverage

Speed up a slow Go test suite: find the slow tests, run independent ones in parallel, share expensive setup once, and cache or split what is left. Measured.

11 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A test suite that takes ten minutes is not just slow. It changes behaviour. People stop running it before they push, they batch changes to avoid paying for it, and a failure arrives long after the edit that caused it. A suite that takes ten seconds gets run on every save. The gap between the two is almost never the amount of code under test, and it is almost always waiting: for containers, databases, sleeps, serial setup, and tests that could have run side by side.

Tested with Go 1.25.6, Apple M1 Max, macOS on Apple Silicon (arm64)

This guide is a method for cutting that time on Go 1.25.6 without cutting what the suite checks. The order matters. Measure, then remove waiting, then share setup, and only after that reduce the work. The lab models the waiting with a 100 millisecond setup per test, which stands for a database connection or a migration.

Measure before changing anything

A suite is slow in a few places, not everywhere. Find them with the tools you already have.

go test -v prints each test's duration on its result line, such as --- PASS: TestX (0.10s). Sort those lines by the number in the parentheses and the slow tests are at the top. For a whole module, go test -json ./... gives Elapsed per test and per package, and a few lines of code can total them. At the package level, the plain ok pkg 12.3s lines already show which packages dominate. A single package that accounts for 70% of the time is a better first target than a hundred small ones.

The lab's last block does the per-test sort: the three slowest subtests each took about 0.10 seconds. The shape of that listing is what to look for. A flat list of tests that all take about the same time points to setup cost paid per test. A few outliers point to specific slow operations, such as a real sleep or a large fixture. Which one you have decides the fix.

Also separate wall time from CPU time. A suite that is slow because tests wait does not use the cores, and a suite slow because it computes does. go test reports wall time, and watching CPU use during a run tells you which. Waiting is cheap to fix with parallelism, while computing is not.

The lab

slow.goGo
package main

import (
	"sync"
	"time"
)

// openDB stands for any 100 millisecond setup: a container, a migration, a connection.
func openDB() string {
	time.Sleep(100 * time.Millisecond)
	return "db"
}

var sharedDB = sync.OnceValue(openDB)

func main() {}
slow_test.goGo
package main

import (
	"fmt"
	"testing"
)

const n = 12

func TestSequential(t *testing.T) {
	for i := range n {
		t.Run(fmt.Sprint(i), func(t *testing.T) {
			_ = openDB()
		})
	}
}

func TestParallel(t *testing.T) {
	for i := range n {
		t.Run(fmt.Sprint(i), func(t *testing.T) {
			t.Parallel()
			_ = openDB()
		})
	}
}

func TestSharedSetup(t *testing.T) {
	for i := range n {
		t.Run(fmt.Sprint(i), func(t *testing.T) {
			t.Parallel()
			_ = sharedDB()
		})
	}
}
Shell
for t in TestSequential TestParallel TestSharedSetup; do
  printf '%-16s' "$t"
  go test -count=1 -run "^$t\$" . | sed -E 's/.*lab\s+([0-9.]+s)/\1/'
done
echo
echo "# the slowest subtests, found from the -v output"
go test -count=1 -v -run '^TestSequential$' . | grep -E '^    --- PASS' | sed -E 's/^ +//' | sort -t'(' -k2 -rn | head -3 | sed -E 's/PASS: TestSequential\//PASS: /'
Text
TestSequential  ok  	example.test/lab	1.491s
TestParallel    ok  	example.test/lab	0.402s
TestSharedSetup ok  	example.test/lab	0.307s

# the slowest subtests, found from the -v output
--- PASS: 9 (0.10s)
--- PASS: 8 (0.10s)
--- PASS: 7 (0.10s)

openDB sleeps for 100 milliseconds, standing for any setup that takes real time. The three tests do the same twelve subtests in three ways.

Run independent tests in parallel

TestSequential runs twelve subtests one after another, each paying 100 ms, and took 1.4 seconds: twelve times 100 ms plus overhead. TestParallel is identical except that each subtest calls t.Parallel(), and it took 0.4 seconds.

That is a big gain and not the one a naive count predicts. Twelve tests of 100 ms in parallel should take 100 ms. They took about four times that, because -parallel limits how many parallel tests run at once, and it defaults to GOMAXPROCS, which is 10 here. Twelve tests ran as a wave of ten and a wave of two, so two rounds of 100 ms plus the sequential overhead. On a machine with two cores, the same suite would take 600 ms.

Parallelism is the cheapest change and the one with the strictest requirement: the tests must be independent. The parallel tests article shows what goes wrong when they are not: shared keys, a forgotten variable, t.Setenv. If your tests wait on I/O, you can raise the limit above the core count with -parallel 32, since a waiting test does not use a core. For CPU-bound tests, more than the core count helps nothing.

Pay for setup once

The third test, TestSharedSetup, does not make each subtest build its own database. sharedDB is a sync.OnceValue: the first caller runs openDB and every other caller waits for the result. Twelve parallel subtests then pay for one setup, and the run took 0.3 seconds, which is close to one setup plus the process start.

The same idea has several forms, with different lifetimes.

  • sync.OnceValue or a package variable. Lazy, built on first use, and only if some test needs it. A good default, because a run with -run for an unrelated test does not pay for it.
  • TestMain. Setup before any test, teardown after the last. Right for a container or a server. It runs even when you select one test, so keep it cheap or lazy, as the TestMain article discusses.
  • A template database. Create the schema once, then copy it per test. Copying is much cheaper than migrating, which is the approach in the Postgres testing article.
  • Transaction rollback per test. One database, no cleanup cost, as in rolling back transactions.

Sharing introduces coupling, so share what the tests do not change (a schema, a connection, a compiled template) and isolate what they do (rows, files, ports). A shared fixture that tests mutate is the road to order-dependent failures.

Remove the sleeps

A time.Sleep in a test is always paid in full. Replace it with a wait on a condition, or take time out of the test with a fake clock or testing/synctest, as in stopping time-based flakes. A suite with thirty tests that sleep a second each saves half a minute by this change alone, and loses its flakiness too. Search the repository for time.Sleep in _test.go files as a first pass, and sort by duration.

Timeouts are the same cost in a different form. A test that waits for a timeout to prove that something does not happen should use a short timeout injected through the code's configuration, not the production value.

Split by cost

Not every test needs to run on every save. Three tiers fit most projects.

  1. Unit tests, no I/O, millisecond each. Run on save and on every commit.
  2. Integration tests against a real database or container, seconds each. Run before push and in CI, behind a build tag or in their own packages, as in the build tag article.
  3. End to end and load tests, minutes. Run in CI on merge or on a schedule.

-short is the built-in lever: tests that call testing.Short() and skip let developers run a fast subset with go test -short ./.... The convention works when a few expensive tests honour it. Do not make it the only mechanism, because skipped tests are tests nobody runs.

Use the cache and the toolchain

go test caches passing results, so an unchanged package costs nothing on the second run. That makes the cost of a full run proportional to what changed, which is a large effect for a module with many packages. Keep it working: avoid tests that read the outside world and so need -count=1, as the cache article explains. In CI, restore the build and test cache between jobs (go env GOCACHE), so a pull request that touches one package recompiles one package.

Other costs sit outside the tests: compile time with -race, linking many test binaries, and go vet which runs before each package's tests. For a large module, go test ./... builds one binary per package, and -p limits how many run at once. If the machine has the cores, the default is right. If memory is short, lowering -p avoids swapping.

Reduce the work, last

Only when waiting and setup are gone should you touch what the tests do. Options, with their costs:

  • Smaller inputs. A test that sorts a million records to check ordering can sort a thousand. This usually loses nothing.
  • Fewer cases in a table. Cases that exercise the same branch add time and no coverage. Remove duplicates, and keep the ones that were added for a bug.
  • Fewer iterations in a stress test. Keep the high count in the nightly job and a low one in the pull request run.
  • Moving a slow check down a tier. A rule that can be tested with a pure function does not need a database.

This is the only step that can lose value, so do it with the coverage report open and a reason for each cut.

When the time is compute, not waiting

Everything above assumes tests that wait. Some suites are slow because they calculate: a large fixture parsed in every test, a cryptographic hash with a real cost factor, a property test with a high iteration count, a golden file comparison on megabytes. For those, parallelism helps up to the core count and no further, and the profiler is the tool.

Take the slowest test and profile only it: go test -run '^TestSlowOne$' -cpuprofile cpu.out -count=1 ./pkg, then go tool pprof -top cpu.out. The top few lines usually name a surprise. Typical findings are a password hash left at its production work factor (every test that creates a user pays for it; use the minimum cost in tests), a regular expression compiled inside a loop, a JSON fixture decoded again in each subtest, or logging at debug level into a buffer. Each is a one-line fix. Fix the biggest line and measure again before moving to the next, because the profile changes shape after every fix.

Test-only configuration is a legitimate lever here. A bcrypt cost of 4 instead of 12, a smaller key size for RSA fixtures, a shorter retry backoff, a tiny batch size, an in-memory store: each is chosen in the test's setup, not by an if testing branch in production code. If production code needs a special case to be fast in tests, it should accept the setting as a parameter.

Containers and other heavy dependencies

A container is the largest single cost in many Go suites: seconds to start a database, and again for each test package that starts its own. The fixes are structural.

  • Start one per package, not one per test. Do it in TestMain or lazily with sync.OnceValue, and give each test an isolated schema or a rolled-back transaction.
  • Start one per run, across packages. Test packages run as separate processes, so they cannot share a TestMain. Start the database once outside go test, from a Makefile target or a CI service, and have the tests read its address from an environment variable. Locally, the same variable can point at a database that stays up between runs, which makes the second run start in milliseconds.
  • Pin and pre-pull images. An image that has to be downloaded on every CI run turns a 6 second start into a minute. Cache it, or bake it into the runner.
  • Prefer the lighter engine when it is faithful. SQLite in memory for portable SQL, miniredis for ordinary Redis commands, httptest for HTTP. The articles on in-memory SQLite and miniredis show what each one can and cannot stand in for.

Splitting a suite across machines

When a suite is as fast as it will get on one machine and still too slow, split it. The unit of work is the package: go list ./... gives the list, and a CI matrix can run a quarter of it per job. Split by recorded duration rather than by name, so that one job does not get all the slow packages. The test result JSON from the previous run supplies those durations, and a small script can balance the buckets. Remember that coverage and the race detector produce per-job outputs which need merging, and that a job that fails to start must not be reported as a pass.

Things that make a suite slower after you fixed it

Speed regresses quietly. A new test adds a second of sleep. A fixture grows. Someone removes t.Parallel to fix a flake and never puts it back. Guard against it: print the slowest ten tests at the end of each CI run, so the list is visible in every build, and set a package-level -timeout close to the current time plus a margin. A suite that has to fit in a 60 second budget gets noticed when it takes 90, and a suite with no budget grows until it takes ten minutes.

Where speed stops being the goal

A fast suite that does not test the risky parts is worse than a slow one that does. If a change makes a test faster by mocking out the thing that used to break, you have bought speed with confidence. The rule of thumb is that each test should be as fast as it can be while still using the real component it exists to check. Integration tests that touch the real database are slow on purpose, and the fix for their cost is shared setup and parallelism, not a mock. The test pyramid article puts the tiers in proportion, and the Go testing hub places this practice in context.

The flags used here, -parallel, -p and -short, are documented in the cmd/go testing flags, and sync.OnceValue in the sync package reference.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.