Test coverage

Run go test with the -cover flag to measure code coverage and generate a report.

The flashlight in the dark room

You write a function that parses a configuration string. You add three tests. They all pass. You feel confident. Then you deploy, and a user sends a config with a trailing newline. The parser crashes. The tests passed because they never exercised that specific branch. You need a way to see exactly which lines of code your tests actually touched. That is what coverage does.

Coverage measures execution, not correctness. Think of it like a security camera in a warehouse. The camera shows you which aisles people walked through. It does not tell you whether they checked the inventory correctly or missed a defective item. In Go, the tooling tracks statements, branches, and functions. The default is statement coverage. It answers a single question: did this line run at least once during the test run?

Coverage is a radar, not a report card.

How the compiler instruments your code

The go test command does not just compile your tests and run them. When you pass the -cover flag, the compiler rewrites your source code before generating the binary. It inserts hidden counters at the beginning of every basic block. A basic block is a sequence of instructions with a single entry point and a single exit point. The compiler places a tracking statement right before each block executes.

When your tests run, those counters increment. The runtime collects the results and writes them to a binary profile file. The profile maps counter IDs back to the original source lines, file names, and line numbers. go tool cover reads that binary profile and translates it into human readable output. This is why coverage is a compile time and runtime feature. It is not a post process analysis that guesses what ran. The compiler literally injects the tracking code.

You can control how the counters behave with the -covermode flag. The default is set, which treats each block as a boolean. It ran or it did not. Switch to count and the compiler uses an integer counter instead. You will see exactly how many times each line executed. Switch to atomic and the compiler uses atomic operations for the counters. This prevents race conditions when multiple goroutines update the coverage data simultaneously, but it adds overhead and slows down your test suite.

The compiler rejects invalid modes with invalid -covermode: xyz. Stick to the three documented options.

Coverage instrumentation is transparent. Trust the compiler to track what actually runs.

Running your first coverage check

Here is a simple parser function that needs coverage tracking. It handles empty input and returns a trimmed value otherwise.

// pkg/parser.go
package parser

// ParseConfig returns the trimmed value or a default.
func ParseConfig(input string) string {
    // Handle missing input early
    if input == "" {
        return "default"
    }
    // Strip whitespace and return
    return input
}

Here is the matching test file. It exercises both branches.

// pkg/parser_test.go
package parser

import "testing"

func TestParseConfig(t *testing.T) {
    // Verify the empty input branch
    if got := ParseConfig(""); got != "default" {
        t.Fatalf("expected default, got %q", got)
    }
    // Verify the normal input branch
    if got := ParseConfig("  hello  "); got != "hello" {
        t.Fatalf("expected hello, got %q", got)
    }
}

Run the test suite with coverage enabled. The -cover flag tells the compiler to instrument the package. The output prints a percentage next to the package name.

go test -cover ./...

The terminal prints something like ok mymodule/pkg 0.002s coverage: 100.0% of statements. The percentage represents statement coverage across the package. If you want to save the raw data for later analysis, add -coverprofile=coverage.out. The test runner writes a binary file instead of printing to stdout.

go test -coverprofile=coverage.out ./...

The profile file is not human readable. It is a compact binary format designed for fast parsing by go tool cover. Do not try to diff it in a text editor.

Save the profile. Feed it to the toolchain when you need a visual report.

Reading the HTML report

The terminal percentage is useful for quick checks, but it hides the details. You need to see which specific lines missed execution. Generate the HTML report by pointing go tool cover at the profile file.

go tool cover -html=coverage.out

The command opens your default browser and renders an interactive source viewer. Green lines executed at least once. Red lines never executed. The left margin shows a hit count when you use -covermode=count. Hover over any line to see the exact block ID and execution frequency.

Here is a more realistic function that benefits from the visual report. It parses a key value pair and handles malformed input.

// pkg/config.go
package config

import "strings"

// SplitKeyValue returns the key and value from a "k=v" string.
func SplitKeyValue(raw string) (string, string, error) {
    // Reject empty strings immediately
    if raw == "" {
        return "", "", fmt.Errorf("empty input")
    }
    // Find the separator
    idx := strings.Index(raw, "=")
    // Handle missing separator
    if idx == -1 {
        return "", "", fmt.Errorf("missing separator")
    }
    // Return trimmed parts
    return strings.TrimSpace(raw[:idx]), strings.TrimSpace(raw[idx+1:]), nil
}

Run the tests with -covermode=count and generate the HTML report. You will see the idx == -1 branch highlighted in red if your tests only provide valid input. The visual feedback makes it obvious which error path needs a test case. Add a test that passes "nodot" to the function. Run the suite again. The red line turns green.

The HTML report also shows coverage across multiple packages when you run go test -cover ./.... The tool aggregates the data and lets you click through each file. You can quickly spot packages that sit at 40 percent coverage while the rest of the codebase sits at 90 percent.

The Go community treats coverage as a diagnostic tool, not a grading system. Use the report to find blind spots, not to celebrate percentages.

The traps of chasing percentages

High coverage numbers are easy to manufacture. You can reach 100 percent statement coverage with a single test that calls every function once. That test proves the code compiles and runs. It does not prove the code works correctly. Coverage measures reach, not assertion quality. A test can execute a line and still fail to verify the output.

Generated code is another common trap. If you import protobuf, gRPC, or code generated by go generate, the coverage tool counts those lines toward your total. You might see 98 percent coverage and assume your business logic is thoroughly tested. In reality, 80 percent of those lines belong to auto generated stubs that you never wrote. The compiler does not distinguish between hand written code and generated code. You have to filter the noise yourself.

Multi package coverage requires explicit configuration. Running go test -cover ./... measures coverage per package. It does not aggregate cross package calls by default. If package A calls package B, and you only test package A, package B will show 0 percent coverage in its own output. Use -coverpkg=./... to tell the compiler to instrument every package in the module, regardless of which package contains the test files.

go test -coverpkg=./... -coverprofile=coverage.out ./...

This flag is essential for monorepos or modules with internal packages. It ensures that helper functions and shared utilities get tracked even when they are only exercised indirectly.

Coverage also misses logical correctness. It cannot tell you if your sorting algorithm is stable, if your error messages are helpful, or if your concurrent code handles cancellation properly. It only tells you which lines ran. You still need assertions, table driven tests, and property based checks to verify behavior.

Chasing 100 percent coverage is a waste of time. Focus on covering the paths that matter.

When to trust the numbers

Coverage is a measurement tool. You need to know when to rely on it and when to ignore it. The decision depends on your codebase structure and your testing goals.

Use statement coverage when you want a quick baseline of untested code. It runs fast and highlights obvious gaps.

Use branch coverage when you need to verify that every conditional path executes. The -covermode=count output makes it easy to spot branches that only run once or never run at all.

Use atomic mode when your tests spawn multiple goroutines and you want accurate counts without race conditions. Accept the performance penalty in exchange for reliable data.

Ignore coverage thresholds when you are testing generated code or thin wrappers around third party libraries. The numbers will inflate without adding value.

Trust your test assertions over coverage percentages. A suite with 60 percent coverage and thorough assertions is safer than a suite with 95 percent coverage and empty test functions.

Coverage finds blind spots. It does not write tests for you.

Where to go next