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 to atomic whenever you also pass -race, because the race detector’s instrumentation is incompatible with the plain, non-atomic counters that set and count use.

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 -cover parses 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, each case, 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, which go tool cover can 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 -coverprofile in CI so coverage is attributed across package boundaries, not just within each package’s own tests.
  • Pair -race with coverage runs for concurrent code; Go will switch to -covermode=atomic automatically, keeping the instrumentation itself race-free.
  • Prioritize covering error-handling branches (the if err != nil paths) — 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 main wiring).

Practice Exercises

  • Write a function FizzBuzz(n int) string that 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, run go test -coverprofile=coverage.out, and use go tool cover -func=coverage.out to confirm all four branches are covered.
  • Take the Grade function from Example 2 and write a complete test that reaches 100% statement coverage, including the missing 70–89 range. Verify with go test -cover that the percentage rises from 80% to 100%.
  • Create two packages, validate and orders, where orders imports and calls a function from validate but only orders has test files. Run go test -cover ./... and observe that validate shows [no test files]; then rerun with -coverpkg=./... and compare the difference.

Summary

  • go test -cover instruments 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=FILE saves detailed data that go tool cover -func and go tool cover -html can turn into a function table or a visual, line-by-line report.
  • -covermode has three settings: set, count, and atomic; Go switches to atomic automatically when -race is 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.