Integration Tests — Optimize¶
A 12-minute integration suite blocks every pull request. Below are concrete levers that bring it under three minutes without losing coverage. Apply them in order; measure after each.
Lever 1 — Container reuse across tests¶
The slowest part of most suites is starting Docker images. Spin a single Postgres instance per package via TestMain, then create one database per test. Database creation takes ~30 ms; container start takes ~3 s.
func TestMain(m *testing.M) {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("template"),
postgres.BasicWaitStrategies())
if err != nil { log.Fatal(err) }
defer pg.Terminate(ctx)
adminDSN, _ = pg.ConnectionString(ctx, "sslmode=disable")
os.Exit(m.Run())
}
func newDB(t *testing.T) *sql.DB {
name := "test_" + uniqueSuffix()
admin := mustOpen(adminDSN)
_, err := admin.Exec("CREATE DATABASE " + name)
require.NoError(t, err)
t.Cleanup(func() {
admin.Exec("DROP DATABASE " + name)
admin.Close()
})
return mustOpen(strings.Replace(adminDSN, "/template", "/"+name, 1))
}
Even better, pre-apply migrations once into a template database and let each test do CREATE DATABASE testN TEMPLATE template. The template copy is a near-instant Postgres operation.
Lever 2 — Transactional fixtures¶
For read-heavy tests, share a populated database and wrap each test in a transaction that rolls back at t.Cleanup. No CREATE DATABASE cost at all.
func newTx(t *testing.T, db *sql.DB) *sql.Tx {
tx, err := db.BeginTx(context.Background(), nil)
require.NoError(t, err)
t.Cleanup(func() { _ = tx.Rollback() })
return tx
}
Only works when the system under test accepts *sql.Tx or a DBTX interface — design your repositories with that in mind. Tests that rely on commit triggers cannot use this pattern.
Lever 3 — Parallelize at the package level¶
go test ./... runs packages in parallel by default. Move integration tests that need their own database into their own package so they get their own TestMain-created container. Eight cores plus eight containers run eight suites concurrently.
Inside a package, use t.Parallel() for tests that are independent of each other once they have fresh databases.
Lever 4 — Skip Docker pulls in CI¶
GitHub Actions, GitLab CI and Buildkite all support image caching. Push your service images to a local registry once per day and pull them by digest. A hot cache turns a 2.5 s pull into a 50 ms one.
For GitHub Actions:
- name: Cache Docker images
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: docker-${{ hashFiles('internal/testenv/images.go') }}
Lever 5 — Trim wait strategies¶
testcontainers-go defaults wait for a log line or TCP port. Setting an overly conservative wait.ForLog("ready") with a 60 s timeout costs time on every run. Use wait.ForSQL for databases — it returns the instant the driver can SELECT 1:
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForSQL("5432/tcp", "pgx", func(host string, p nat.Port) string {
return fmt.Sprintf("postgres://test:test@%s:%s/test?sslmode=disable", host, p.Port())
}).WithStartupTimeout(15 * time.Second),
}
For Kafka, prefer wait.ForLog("Kafka Server started") over a generic TCP probe — the broker accepts connections before it accepts produce requests.
Lever 6 — Compile once, test many¶
go test -count=1 ./internal/integration/... recompiles when sources change. Use go test -c -o int.test ./internal/integration and run the binary directly in CI loops. Saves the go build phase on repeated runs.
In CI, set GOFLAGS=-trimpath -ldflags=-s -w to avoid debug info bloat that does not help integration tests.
Lever 7 — Cut the suite into tiers¶
make test-unit— under 30 seconds, runs on every save.make test-integration— under 3 minutes, runs on every push.make test-e2e— under 15 minutes, runs nightly.
Wire the -tags=integration build tag into the second tier. Developers do not pay for E2E latency during inner-loop work.
Lever 8 — Watch the long pole¶
Add -v and gotestsum --jsonfile out.json, then sort by duration:
The top ten usually account for over 60% of total runtime. Optimize those first; the rest can stay as-is.
Lever 9 — Drop heavy fixtures¶
Some tests load a 10 000-row fixture they never query. Audit:
Trim fixtures to the smallest set that exercises the code path. A 100-row dataset is usually enough; if it is not, that is a hint the test is actually a benchmark in disguise.
Lever 10 — Avoid per-test container start where unnecessary¶
A common antipattern: one test spins its own Postgres. The fix is to add that test to the package's shared container via TestMain. Saved time: 3 seconds per offending test, easily 30 seconds across a suite.
Result¶
Twelve minutes drops to two minutes forty. Engineers stop tagging tests as t.Skip() and integration confidence rises. The CI bill drops proportionally — container-minutes are billed by the minute on most hosted runners.
Measure before and after. The numbers convince skeptics in a way that "trust me, it'll be faster" never does.
11. Lever 11 — Avoid -count=1 in the hot path¶
go test -count=1 disables the test result cache. That cache is one of Go's most under-used speedups. With the cache enabled, tests for unchanged packages return instantly:
$ go test ./...
ok example.com/api (cached)
ok example.com/store (cached)
ok example.com/shortener 3.61s
Caveat: the cache only applies when nothing affecting the test changed (source, env vars, command-line flags). For most workflows this is a free win. CI sometimes needs -count=1 to guarantee fresh runs — acceptable there, costly during local development.
12. Lever 12 — Use gotestsum for clearer output¶
gotestsum wraps go test, parses JSON output, and provides better formatting plus JUnit XML:
Advantages:
- Clear pass/fail per test with timing.
- JUnit XML for CI dashboards.
- A
--rerun-fails=2flag retries failed tests (use sparingly).
It does not change the speed of the tests themselves but reduces analysis time after a failure.
13. Lever 13 — Selective integration testing¶
When a PR touches internal/payments/, you may not need to run the entire suite. A small script computes affected packages:
PKGS=$(go list -deps -tags=integration ./... | sort -u)
CHANGED=$(git diff --name-only origin/main..HEAD | grep -E '\.(go|sql|yaml)$')
AFFECTED=$(echo "$CHANGED" | xargs -n1 dirname | sort -u)
TO_TEST=$(comm -12 <(echo "$PKGS") <(echo "$AFFECTED"))
go test -tags=integration $TO_TEST
Used carefully, this scales sublinearly with codebase size. The trade-off is increased complexity in CI scripts.
14. Lever 14 — Connection pool right-sizing¶
A *sql.DB with SetMaxOpenConns(50) and 100 parallel tests each opening a handle means 5000 connections to Postgres — well past its default max_connections=100. Symptoms include FATAL: too many clients errors and slow tests.
Lower per-handle: SetMaxOpenConns(5). Increase the database's limit only if you have a real reason.
15. Lever 15 — Disable WAL on test databases¶
For tests that do not exercise crash recovery, disable Postgres's write-ahead log:
ALTER SYSTEM SET synchronous_commit = 'off';
ALTER SYSTEM SET fsync = 'off';
ALTER SYSTEM SET full_page_writes = 'off';
Or, simpler: launch Postgres with command: ["postgres", "-c", "fsync=off"] in the testcontainers options. Inserts speed up 2-3x; durability is irrelevant because the database is discarded after each test.
Never apply these settings to a production database.
16. Lever 16 — Avoid n+1 in fixture seeding¶
A common antipattern in test setup:
Each call does one INSERT. Total: 1000 round trips, ~5 seconds. Fix with a bulk insert:
func Users(t *testing.T, db DBTX, n int) []User {
t.Helper()
rows := make([]any, 0, n*3)
args := make([]string, 0, n)
for i := 0; i < n; i++ {
rows = append(rows, fmt.Sprintf("u%d", i), "email", "active")
args = append(args, fmt.Sprintf("($%d,$%d,$%d)", i*3+1, i*3+2, i*3+3))
}
query := "INSERT INTO users(name,email,status) VALUES " + strings.Join(args, ",")
_, err := db.Exec(query, rows...)
if err != nil { t.Fatal(err) }
// ... read back and return
}
One round trip. ~50 ms.
17. Cumulative effect¶
Applying levers 1 through 16 to a representative 1500-test integration suite:
| Stage | Wall time |
|---|---|
| Before any optimization | 12 min |
| Container reuse (lever 1) | 8 min |
| Template + COPY (lever 1 cont.) | 5 min |
| Parallelism (lever 3) | 4 min |
| Image cache (lever 4) | 3 min 20 s |
| Test cache (lever 11) | 2 min 40 s |
Numbers vary by suite. The methodology — measure, optimize the long pole, re-measure — does not.
18. When the suite is already fast¶
If the integration suite runs in under three minutes, optimizing further has diminishing returns. Spend the optimization budget on:
- Unit-test feedback loop (under 30 seconds is the target).
- E2E test reliability (often the long pole of full release flow).
- Local developer experience (faster builds, hot-reload).
A balanced ecosystem beats a single hyper-optimized layer.
19. Closing the optimize page¶
Performance work is empirical. Measure, change one thing, measure again. Publish the before/after numbers; engineering trust grows when optimization is documented. Resist the urge to micro-optimize tests that are already short; the wall-clock impact is invisible to users.
The Professional page connects these mechanics to the broader organizational picture.