Test Coverage
Test coverage tells you which lines of your code actually ran while your test suite executed. Go builds this measurement directly into the toolchain: no third-party library is required, and a single flag on go test instruments your code, runs it, and reports the percentage of statements that were exercised. Coverage is not a guarantee of correctness, but it is one of the fastest ways to find code that nobody is testing at all.
Overview: What Test Coverage Measures
When you run go test -cover, the Go toolchain does not simply watch your program execute. It rewrites a temporary copy of your source: before compiling, it walks the syntax tree and inserts a counter increment at the start of every basic block (a straight-line run of statements with no branching in or out). A function with an if/else gets at least two counters, one per branch; a switch gets one per case. This instrumented copy is what actually gets compiled and run for the test binary — your original source on disk is untouched.
After the tests finish, Go reads back which counters were incremented at least once and divides that by the total number of counters to produce the percentage you see, such as coverage: 83.3% of statements. This is statement coverage (in Go’s specific case, block coverage), not full branch coverage. A single line like if a && b { counts as covered the moment it runs once, even if you never tested the case where a is true and b is false. This distinction matters: 100% statement coverage is a floor, not a ceiling — it tells you no code path was completely skipped, but it does not prove every logical combination was tested.
Coverage Modes
Go supports three counting modes, chosen with -covermode:
- set (default) — records only whether each block ran at least once (true/false). Cheapest, and the default for ordinary test runs.
- count — records how many times each block ran. Useful for finding hot paths, but not required for a simple pass/fail coverage percentage.
- atomic — like
count, but uses atomic increments so it is safe when multiple goroutines hit the same counter concurrently. Go automatically switches toatomicwhenever you also pass-race, because the race detector’s instrumentation is incompatible with the plain, non-atomic counters thatsetandcountuse.
Syntax
go test -cover
go test -coverprofile=coverage.out
go test -covermode=atomic -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out
go tool cover -html=coverage.out -o coverage.html
| Flag / Command | Purpose |
|---|---|
-cover |
Enables coverage instrumentation and prints a one-line summary after the test run. |
-coverprofile=FILE |
Writes a detailed, per-block coverage profile to FILE for later inspection. |
-covermode=MODE |
Selects set, count, or atomic counting. |
-coverpkg=PATTERN |
Instruments packages matching PATTERN even if the tests live in a different package (needed for cross-package coverage). |
go tool cover -func=FILE |
Prints per-function coverage percentages from a saved profile. |
go tool cover -html=FILE |
Opens (or writes, with -o) an HTML report that highlights covered lines in green and uncovered lines in red. |
Examples
Example 1: A Basic Coverage Report
Start with a small function and a complete table-driven test for it.
package main
import "fmt"
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
func main() {
fmt.Println(Abs(-5))
fmt.Println(Abs(5))
}
Output:
5
5
Now the test file that would live alongside it as abs_test.go:
package main
import "testing"
func TestAbs(t *testing.T) {
tests := []struct {
name string
in int
want int
}{
{"negative", -5, 5},
{"positive", 5, 5},
{"zero", 0, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Abs(tc.in)
if got != tc.want {
t.Errorf("Abs(%d) = %d, want %d", tc.in, got, tc.want)
}
})
}
}
Running it with coverage enabled:
$ go test -cover
PASS
coverage: 100.0% of statements
ok example.com/abs 0.002s
Both the if n < 0 branch and the fall-through return n ran (the -5 and 5 test cases each exercise one), so every block in Abs was hit and coverage reads 100%.
Example 2: A Coverage Gap Reveals an Untested Branch
package main
import "fmt"
func Grade(score int) string {
switch {
case score >= 90:
return "A"
case score >= 80:
return "B"
case score >= 70:
return "C"
default:
return "F"
}
}
func main() {
fmt.Println(Grade(95))
fmt.Println(Grade(60))
}
Output:
A
F
Suppose the test file only covers the extreme cases:
package main
import "testing"
func TestGrade(t *testing.T) {
tests := []struct {
score int
want string
}{
{95, "A"},
{60, "F"},
}
for _, tc := range tests {
got := Grade(tc.score)
if got != tc.want {
t.Errorf("Grade(%d) = %q, want %q", tc.score, got, tc.want)
}
}
}
$ go test -coverprofile=coverage.out
PASS
coverage: 80.0% of statements
ok example.com/grade 0.002s
$ go tool cover -func=coverage.out
example.com/grade/grade.go:6: Grade 80.0%
example.com/grade/grade.go:14: main 100.0%
total: (statements) 83.3%
The case score >= 80 and case score >= 70 blocks never executed, because no test case landed in the 70–89 range. The function-level breakdown from go tool cover -func pinpoints exactly which function is under-tested, and running go tool cover -html=coverage.out would show those two lines highlighted in red in the browser.
Example 3: Coverage on Concurrent Code
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
fmt.Println(c.Value())
}
Output:
100
Its test spins up goroutines that all call Inc concurrently:
package main
import (
"sync"
"testing"
)
func TestCounter_Concurrent(t *testing.T) {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Inc()
}()
}
wg.Wait()
if got := c.Value(); got != 50 {
t.Errorf("Value() = %d, want 50", got)
}
}
$ go test -race -covermode=atomic -coverprofile=coverage.out
PASS
coverage: 100.0% of statements
ok example.com/counter 0.015s
$ go tool cover -html=coverage.out
Because many goroutines increment the same coverage counters simultaneously, plain set or count mode counters (ordinary, non-atomic memory writes) could themselves race. Passing -race makes Go select -covermode=atomic automatically so the coverage instrumentation itself stays race-free.
How It Works Step by Step
- 1.
go test -coverparses your package’s source into an abstract syntax tree. - 2. It inserts a counter variable and an increment statement at the start of every basic block (each branch of an
if, eachcase, each loop body, and so on). - 3. It compiles this instrumented copy into a temporary test binary — your original files on disk are never modified.
- 4. The test binary runs as normal; every time execution passes through a block, its counter increments.
- 5. When the tests finish, Go reads the final counter values and computes covered-blocks / total-blocks as a percentage.
- 6. If you passed
-coverprofile, the raw per-line counter data is written to the profile file, whichgo tool covercan later turn into a function table or an HTML report.
Common Mistakes
Mistake 1: Calling code without asserting anything
A test that executes every branch but checks nothing still reports 100% coverage — the counters only track that a line ran, not that the result was verified.
// Wrong: exercises every branch but asserts nothing.
func TestGradeCoverage(t *testing.T) {
Grade(95)
Grade(85)
Grade(75)
Grade(50)
}
This reaches 100% statement coverage and would still pass even if Grade returned the wrong letter every single time, because nothing compares the result to an expected value. Always assert:
// Correct: same branches, but the results are checked.
func TestGradeCoverage(t *testing.T) {
tests := []struct {
score int
want string
}{
{95, "A"},
{85, "B"},
{75, "C"},
{50, "F"},
}
for _, tc := range tests {
if got := Grade(tc.score); got != tc.want {
t.Errorf("Grade(%d) = %q, want %q", tc.score, got, tc.want)
}
}
}
Mistake 2: Trusting per-package coverage in a multi-package project
By default, go test ./... reports each package’s coverage using only the tests that live in that same package. A package with zero test files simply reports no coverage line at all, and code exercised only indirectly by another package’s tests is not counted against it.
$ go test -cover ./...
ok example.com/orders 0.01s coverage: 91.2% of statements
ok example.com/billing 0.00s coverage: [no test files]
The billing package looks untested, which may be accurate, but in a larger codebase this pattern hides cross-package blind spots. Use -coverpkg to attribute coverage across the whole module regardless of which package’s test files did the calling:
$ go test -coverpkg=./... -coverprofile=coverage.out ./...
$ go tool cover -func=coverage.out
Best Practices
- Treat coverage as a tool for finding untested code, not as a quality score to maximize for its own sake — 100% coverage with weak assertions is worse than 80% coverage with strong ones.
- Commit to reviewing the HTML report (
go tool cover -html) occasionally; red lines are far easier to spot visually than scanning a percentage. - Use
-coverpkg=./...together with-coverprofilein CI so coverage is attributed across package boundaries, not just within each package’s own tests. - Pair
-racewith coverage runs for concurrent code; Go will switch to-covermode=atomicautomatically, keeping the instrumentation itself race-free. - Prioritize covering error-handling branches (the
if err != nilpaths) — these are exactly the code paths most likely to be skipped by happy-path-only tests, and the ones most likely to hide real bugs. - Don’t set a rigid organization-wide 100% coverage gate; instead flag files or functions that dropped in coverage compared to the previous run, which catches regressions without punishing legitimately hard-to-test code (like
mainwiring).
Practice Exercises
- Write a function
FizzBuzz(n int) stringthat returns"Fizz"for multiples of 3,"Buzz"for multiples of 5,"FizzBuzz"for multiples of both, and the number itself otherwise. Write a table-driven test, rungo test -coverprofile=coverage.out, and usego tool cover -func=coverage.outto confirm all four branches are covered. - Take the
Gradefunction from Example 2 and write a complete test that reaches 100% statement coverage, including the missing 70–89 range. Verify withgo test -coverthat the percentage rises from 80% to 100%. - Create two packages,
validateandorders, whereordersimports and calls a function fromvalidatebut onlyordershas test files. Rungo test -cover ./...and observe thatvalidateshows[no test files]; then rerun with-coverpkg=./...and compare the difference.
Summary
go test -coverinstruments a temporary copy of your code with counters at each basic block and reports covered/total as a percentage.- Go’s coverage is statement (block) coverage, not full branch coverage — 100% means every line ran at least once, not that every logical combination was tested.
-coverprofile=FILEsaves detailed data thatgo tool cover -funcandgo tool cover -htmlcan turn into a function table or a visual, line-by-line report.-covermodehas three settings:set,count, andatomic; Go switches toatomicautomatically when-raceis enabled so the counters themselves stay race-free.- By default, coverage is scoped per package; use
-coverpkg=./...to attribute coverage across an entire module. - A high coverage percentage from tests with no real assertions is misleading — always pair coverage with meaningful checks on the results.
