Go ships a coverage tool built into go test. No separate installation, no configuration files for basic use — just a flag. Understanding what coverage measures, how to interpret it, and how to enforce it in CI separates a mature Go project from one that’s just hoping the tests are good enough.
For testing fundamentals see Go testing basics.
Running Coverage
The simplest form shows a percentage per package:
go test -cover ./...
# ok myapp/handlers coverage: 87.3% of statements
# ok myapp/service coverage: 92.1% of statements
# ok myapp/repository coverage: 76.4% of statements
For a deeper look, generate a coverage profile — a file recording which statements were executed:
# Write profile for all packages
go test -coverprofile=coverage.out ./...
# Merge profiles from multiple runs (useful for integration tests)
go test -coverprofile=unit.out ./...
go test -run Integration -coverprofile=integration.out ./...
Analyzing Coverage
Per-function breakdown — which functions have low coverage:
go tool cover -func=coverage.out
# myapp/handlers/users.go:handleGetUser 87.5%
# myapp/handlers/users.go:handleCreateUser 100.0%
# myapp/service/order.go:PlaceOrder 62.5% ← low coverage
# total: 83.2%
HTML report — visual heat map of covered (green) vs uncovered (red) lines:
go tool cover -html=coverage.out
# Opens browser with annotated source files
The HTML report is the most useful for understanding what isn’t covered — you can see whether the uncovered lines are error handling paths, rarely-used features, or core business logic.
Coverage Modes
Three modes control what the coverage tool tracks:
-covermode=set(default): was this statement executed? (yes/no)-covermode=count: how many times was this statement executed?-covermode=atomic: like count but safe for concurrent tests (slightly slower)
Use count or atomic when you want to identify “hot” code paths — statements executed thousands of times — vs cold paths that might need more stress testing:
go test -covermode=count -coverprofile=coverage.out ./...
go tool cover -html=coverage.out # darker green = more executions
Enforcing Coverage in CI
A common CI gate: fail the build if total coverage drops below a threshold. Parse the total from go tool cover -func:
# Get total coverage percentage
COVERAGE=$(go test -coverprofile=coverage.out ./... && \
go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
# Fail if below 80%
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage $COVERAGE% is below threshold of 80%"
exit 1
fi
echo "Coverage: $COVERAGE%"
A more structured approach uses go-coverage-report:
# .github/workflows/test.yml
- name: Run tests with coverage
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: Check coverage threshold
run: |
TOTAL=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Total coverage: $TOTAL%"
awk -v threshold=80 'BEGIN{ if ('$TOTAL' < threshold) {
print "Coverage "$TOTAL"% below threshold "threshold"%"; exit 1
}}'
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: coverage.out
What Coverage Doesn’t Measure
Coverage percentage is a leading indicator, not a quality measure. 100% statement coverage doesn’t mean:
- All code paths through complex conditions are tested
- Edge cases are covered (empty slices, nil values, negative numbers)
- The tests verify correct behavior — only that the lines were executed
- Error paths were tested to completion
A function with if err != nil { return err } is “covered” if it enters that branch, but coverage can’t tell you if err was actually non-nil or if the test mocked it away. Good coverage means testing the paths that matter, not maximizing the percentage.
A useful mental model: coverage finds code your tests never touch. Fixing coverage means looking at what’s uncovered and deciding: should this be tested? If yes, write the test. If the code is dead code, delete it.
golangci-lint: Static Analysis for Quality
Test coverage measures runtime behavior; static analysis catches structural issues before tests run. golangci-lint runs dozens of linters in one command:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...
A minimal .golangci.yml that catches the most impactful issues:
# .golangci.yml
linters:
enable:
- gofmt # formatting
- govet # go vet checks
- errcheck # unhandled errors
- staticcheck # comprehensive static analysis
- gosimple # code simplification suggestions
- unused # unused code
- gosec # security issues
- misspell # typos in comments/strings
- noctx # HTTP calls without context
linters-settings:
errcheck:
check-type-assertions: true # flag unchecked type assertions
gosec:
excludes: [G104] # G104 = unhandled errors (errcheck covers this)
issues:
exclude-rules:
- path: _test\.go
linters: [errcheck] # allow unchecked errors in test files
- linters: [staticcheck]
text: "SA1019" # allow deprecated API usage (for now)
Run it in CI:
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
go vet: The Built-In Checker
go vet catches a narrower but high-confidence set of issues — things that are almost certainly bugs:
go vet ./...
Common things go vet catches:
printf-style format string mismatches (%dwith a string argument)- Calling
sync.Mutexby value (should be by pointer) - Unreachable code after
return - Suspicious struct tags (
json:"name,"instead ofjson:"name,omitempty") - Tests that call
t.Fatalfrom goroutines (invalid)
go vet is part of the standard toolchain and runs automatically with go test -vet=all (the default). It’s fast and has no false positives by design — if it flags something, it’s worth fixing.
CI Pipeline Summary
A complete quality gate for pull requests:
# Format check
gofmt -l . | grep -q . && echo "formatting issues found" && exit 1
# Vet (also runs as part of go test, but explicit for visibility)
go vet ./...
# Tests with race detector and coverage
go test -race -coverprofile=coverage.out -covermode=atomic ./...
# Coverage threshold
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
[ "$(echo "$COVERAGE < 75" | bc)" -eq 1 ] && echo "Coverage $COVERAGE% below 75%" && exit 1
# Linting
golangci-lint run ./...
# Vulnerability check
govulncheck ./...
echo "All checks passed — coverage: $COVERAGE%"
Summary
go test -coverprofile=coverage.out ./...generates the coverage data;go tool coveranalyzes it-covermode=countshows execution frequency;-covermode=atomicis safe for parallel tests- HTML report (
go tool cover -html) is the most actionable view — shows exactly which lines aren’t exercised - Enforce a threshold in CI by parsing
go tool cover -functotal line; 75–80% is a reasonable floor for most services - Coverage measures which lines ran, not whether they were tested correctly — high coverage with poor assertions is still poor quality
golangci-lintwitherrcheck,staticcheck, andgoseccatches structural issues that tests miss
Comments