05 · Testing at Scale & CI¶
Level 3, Module 6 covered fuzzing and
benchmarks for a single package. Scaling that to a real codebase means
tracking coverage, separating fast unit tests from slow integration tests
so CI stays useful, and wiring both into a pipeline that runs on every
push. This module builds a real go.mod, generates a real coverage
report, and ends with a GitHub Actions workflow that does the same.
Measuring coverage¶
package calc
import "testing"
func TestAdd(t *testing.T) {
if Add(2, 3) != 5 {
t.Fatal("bad add")
}
}
func TestSub(t *testing.T) {
if Sub(5, 3) != 2 {
t.Fatal("bad sub")
}
}
$ go test -coverprofile=cover.out ./...
ok l4m05 0.619s coverage: 100.0% of statements
$ go tool cover -func=cover.out
l4m05/calc.go:3: Add 100.0%
l4m05/calc.go:4: Sub 100.0%
total: (statements) 100.0%
-coverprofile writes per-statement coverage data to a file; go tool
cover -func turns it into a per-function summary, and go tool cover
-html=cover.out -o cover.html renders a browsable, line-by-line view —
genuinely useful for spotting an entire error-handling branch nobody
exercises, not just a percentage to chase. 100% coverage on two one-line
functions is a toy result — real coverage targets (commonly 70-80% for a
service, higher for a pure library) are a floor to catch untested code
paths, not a proxy for "the tests are good."
Separating fast unit tests from slow integration tests¶
A build tag gates a test file out of the default go test ./... run:
//go:build integration
package calc
import "testing"
func TestAddAgainstRealService(t *testing.T) {
// Placeholder: real integration tests hit a live dependency
// (a database, another service) and are gated behind this build tag
// so `go test ./...` stays fast by default.
if Add(10, 5) != 15 {
t.Fatal("bad add")
}
}
$ go test ./... -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestSub
--- PASS: TestSub (0.00s)
PASS
ok l4m05 0.518s
$ go test -tags=integration ./... -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
=== RUN TestSub
--- PASS: TestSub (0.00s)
=== RUN TestAddAgainstRealService
--- PASS: TestAddAgainstRealService (0.00s)
PASS
ok l4m05 0.530s
Without -tags=integration, TestAddAgainstRealService doesn't even
compile into the test binary — it's not skipped at runtime, it's absent
entirely. This is the standard way to keep go test ./... fast enough to
run on every save while a separate, slower CI job runs the tagged
integration suite against real dependencies (a database container, a
staging service).
A real CI pipeline¶
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
cache: true
- name: Vet
run: go vet ./...
- name: Unit tests with race detector and coverage
run: go test -race -coverprofile=cover.out ./...
- name: Enforce coverage floor
run: |
pct=$(go tool cover -func=cover.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "coverage: ${pct}%"
awk -v p="$pct" 'BEGIN { exit !(p+0 >= 70) }'
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
integration:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Integration tests
run: go test -tags=integration ./...
-race in the unit-test step catches data races
(Level 3, Module 1) on every push,
not just when someone remembers to run it locally. The coverage-floor step
turns "coverage: 62%" from a number nobody looks at into a build that
actually fails when it drops. Splitting integration into its own job with
needs: test means slow, potentially flaky integration tests only run
after the fast unit suite passes, and a failure there doesn't block merges
on unrelated unit-test problems being fixed first.
Go-specific traps¶
go test -raceis significantly slower and more memory-hungry — running it on every local save is often impractical, but it belongs in CI on every push precisely because CI machines can absorb the cost and humans forget to run it manually.- A coverage percentage says nothing about assertion quality — a test that calls a function and asserts nothing about the result still counts as "covered." Coverage tooling answers "was this code executed," never "was this code correctly checked."
- Build tags must be the very first thing in the file, with a blank
line separating
//go:build ...from thepackageclause — get the blank line wrong and the tag silently fails to apply, and the file compiles unconditionally. go vetis not a linter — it catches real bugs (Printfformat mismatches, unreachable code) but not style issues;golangci-lintwrapsgo vetalongside dozens of style/bug-pattern linters and is what most real Go CI pipelines run instead.- Caching
go build/go testartifacts incorrectly across CI runs can hide a failure that would show up on a clean checkout —actions/setup-go's built-incache: truehandles the module cache correctly; don't hand-roll a cache key that also captures build output as if it were still valid after a source change.
How It Actually Works¶
go test's build cache ($GOCACHE) keys every compiled package and, separately,
every test result by a hash of the package's source files, its dependencies'
hashes, and the build flags used — this is why re-running go test ./... with
nothing changed reports "(cached)" instantly instead of recompiling: the hash
matches a previous run's recorded pass/fail outcome, and Go trusts it deterministically.
CI parallelism across matrix jobs (different Go versions, different OSes) works
because each job is a genuinely separate process/container with its own $GOCACHE
and $GOPATH — there's no shared mutable state to race on, unlike parallel tests
within one process, which rely on t.Parallel()'s scheduler-level pausing
(level-3/06) rather than separate processes. Code coverage instrumentation
(-cover) works by rewriting the AST at compile time to insert a counter increment
at the start of every basic block, then dumping those counters to a profile file at
program exit — coverage percentage is just "blocks with a nonzero counter" divided
by "total blocks," which is why coverage can be 100% while still missing entire
logical branches inside a single block.
Cheat sheet¶
| Task | Command |
|---|---|
| Run tests with coverage | go test -coverprofile=cover.out ./... |
| Per-function coverage report | go tool cover -func=cover.out |
| Browsable HTML coverage | go tool cover -html=cover.out -o cover.html |
| Gate a test behind a build tag | //go:build integration (first line, blank line after) |
| Run tagged tests | go test -tags=integration ./... |
| Race detector in CI | go test -race ./... |
Lint beyond go vet |
golangci-lint run (golangci-lint-action in Actions) |
Related lessons¶
- Table tests, subtests, fuzzing, and benchmarks this module scales up: Level 3, Module 6.
- The race detector applied to concurrency patterns directly: Level 3, Module 1.
- Containerizing the service these tests protect: Module 6.
Exercise¶
Add a Makefile (or a justfile) with test, test-integration,
cover, and lint targets that wrap the commands above. Then extend the
CI workflow with a matrix (strategy: matrix: go-version: ["1.22", "1.23"])
so unit tests run against two Go versions in parallel jobs, and confirm in
the Actions UI (or by reading the generated workflow file carefully) that
both versions' jobs are independent — a failure in one doesn't cancel the
other.