How to Use go tool cover for Test Coverage

Run go test -cover to generate data and go tool cover -html to view the report.

The dark lines in your code

You run your test suite. Every single test passes. The terminal prints a green checkmark and a coverage percentage. You stare at the number and wonder what it actually means. Did you test the happy path? The error handling? The edge cases that only appear when a database times out at 3 AM? A percentage tells you how much code executed. It does not tell you which lines stayed dark.

Test coverage is a map of your codebase. Go builds this map by injecting tiny tracking statements into your compiled binary before it runs. Every time a statement executes, the tracker bumps a counter. When the tests finish, Go writes those counters to a file. The go tool cover command reads that file and turns raw numbers into something you can actually read. Think of it like a motion sensor system in a museum. The sensors do not tell you whether the visitors appreciated the art. They only tell you which rooms stayed empty.

Coverage is a diagnostic tool, not a quality score. The Go community treats it as a compass. You use it to find blind spots, not to chase arbitrary percentages. Eighty percent coverage on critical business logic beats one hundred percent coverage on generated boilerplate.

Coverage maps reveal gaps. They do not guarantee correctness.

How the coverage map gets built

The standard library includes the coverage tooling. You do not need third-party packages or complex build scripts. The workflow starts with a single flag attached to go test.

Here is the simplest way to generate a coverage profile and open it in your browser:

# Run tests and write the coverage profile to a file
go test -cover -coverprofile=coverage.out ./...

# Open the interactive HTML report in your default browser
go tool cover -html=coverage.out

The first command runs your tests and saves the execution data to coverage.out. The second command parses that file and launches a local web server. Your browser opens to a color-coded version of your source code. Green lines ran. Red lines did not.

Under the hood, the Go compiler performs a transformation before linking your binary. It wraps every executable statement in a counter increment. If you have a function that adds two numbers, the compiler injects a hidden array access and a plus-one operation around the addition. The runtime records these hits in memory. When the test process exits, the runtime flushes the array to disk in a compact text format.

The coverage.out file uses a simple four-column layout. Each row contains the coverage mode, the starting byte offset, the ending byte offset, and the hit count. The HTML viewer reads those offsets, maps them back to your source files, and highlights the corresponding lines.

This is where most developers get tripped up. Go measures statement coverage, not line coverage. A single statement can span multiple lines. A single line can contain multiple statements. The compiler tracks the smallest unit of execution it can identify. If you write a multi-line if statement, the entire block counts as one statement. If you chain multiple assignments on one line, they count as separate statements. The HTML report reflects this granularity. You will see entire blocks highlighted in green even if only the first line executed. You will see partial red highlights on lines that contain untested branches.

Statement coverage tracks execution paths. It does not measure branch coverage or condition coverage. You need to read the report carefully to understand what actually ran.

Coverage instrumentation happens at compile time. The HTML viewer is just a reader.

Reading the HTML report without losing your mind

The interactive report shows your source code with colored overlays. Clicking a file name in the sidebar switches the view. The top of the page shows the overall percentage for that file. The bottom shows a summary of total statements and covered statements. You can click any line to jump to it in your editor.

Real projects rarely consist of a single file. You will usually run coverage across a package or a module. The ./... pattern tells the test runner to recurse into every subdirectory. The coverage tool merges the results into a single profile. If you only want to cover a specific package, replace the pattern with the package path.

Here is a realistic workflow for a package that handles user authentication:

// auth/auth.go
package auth

// ValidateToken checks if a JWT is valid and returns the user ID.
func ValidateToken(token string) (string, error) {
    // Parse the token and verify the signature
    claims, err := parseJWT(token)
    if err != nil {
        // Return early if the signature is invalid
        return "", err
    }
    // Extract the subject claim
    return claims.Subject, nil
}
// auth/auth_test.go
package auth

import "testing"

// TestValidateTokenHappyPath verifies that a valid token returns the correct ID.
func TestValidateTokenHappyPath(t *testing.T) {
    // Use a mock token that the parser will accept
    token := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
    id, err := ValidateToken(token)
    if err != nil {
        // Fail the test if parsing unexpectedly fails
        t.Fatalf("expected no error, got %v", err)
    }
    // Assert that the extracted ID matches the expected value
    if id != "1234567890" {
        t.Errorf("expected 1234567890, got %s", id)
    }
}

Run the test with coverage and open the report. The if err != nil block will likely show up in red. Your test only exercises the happy path. The error branch never executed. The coverage tool does not judge whether that branch is important. It only reports that the counter never incremented.

You will encounter red lines that you cannot easily test. Defensive checks, logging statements, and panic recovery blocks often fall into this category. The Go community accepts this reality. You do not write tests to prove that log.Println works. You write tests to verify that your application behaves correctly under expected and expected failure conditions. If a red line is truly unreachable in production, you can ignore it. If it represents a missing error case, you write a test that triggers it.

The compiler will reject your coverage run if you mix parallel tests with the default coverage mode. The default mode uses a regular map to track hits. Parallel tests write to that map concurrently. The runtime detects the race and aborts with a fatal error: concurrent map writes panic. You need to switch coverage modes to fix this.

Coverage reports highlight gaps. They do not dictate your testing strategy.

Picking the right coverage mode

The -covermode flag controls how the compiler instruments your code. The default mode is set. It records whether a statement executed at least once. It uses minimal memory and runs fast. It is sufficient for most unit test suites.

The count mode records how many times each statement executes. It replaces the boolean flag with an integer counter. This mode reveals hot paths. You will see which functions run dozens of times during your test suite and which run once. It uses slightly more memory but provides valuable profiling data.

The atomic mode uses atomic operations instead of regular memory writes. It is safe for parallel tests and benchmarks. It adds a small performance overhead because atomic operations prevent CPU cache invalidation. You use it when you run tests with the -parallel flag or when you combine coverage with benchmark runs.

Here is how you apply these modes in practice:

# Default mode: fast, boolean tracking, safe for sequential tests
go test -cover -covermode=set -coverprofile=set.out ./...

# Count mode: tracks execution frequency, useful for profiling hot paths
go test -cover -covermode=count -coverprofile=count.out ./...

# Atomic mode: thread-safe counters, required for parallel tests or benchmarks
go test -cover -covermode=atomic -coverprofile=atomic.out ./... -parallel=4

You can also combine coverage with benchmarks. The test runner executes benchmarks after tests. The coverage tool records hits from both phases. The final percentage reflects the combined execution. This is useful when your benchmarks exercise code paths that your unit tests skip.

The HTML viewer does not change based on the mode. It only displays the hit counts differently. In set mode, covered lines show a 1. In count mode, they show the actual execution count. In atomic mode, they show the count with a note that atomic operations were used.

You can generate a plain text summary instead of opening a browser. The -func flag prints a table of coverage percentages per function. This output is easy to pipe into shell scripts or CI dashboards.

# Print a function-level coverage table to stdout
go tool cover -func=coverage.out

The output lists each function, its file location, and its coverage percentage. You can grep for functions below a threshold. You can sort the results. You can fail your CI pipeline if the overall percentage drops below a target. The tool gives you raw data. You decide how to enforce it.

Convention matters here. The Go ecosystem expects test files to end with _test.go. The compiler automatically excludes them from production builds. The coverage tool respects this boundary. You will never see coverage data for test helpers unless you explicitly include them. You also never pass pointers to strings for coverage flags. The tool accepts plain strings and file paths. Keep the commands simple. Let the tool handle the parsing.

Coverage modes trade memory for precision. Pick the one that matches your test execution pattern.

Picking the right tool for the job

Use go test -cover when you want a quick statement-level overview of your current package. Use go tool cover -html=profile.out when you need a visual map to spot missed branches and untested error paths. Use -covermode=count when you want to see how many times each line executes across your entire test suite. Use -covermode=atomic when you run tests in parallel or combine coverage with benchmarks to prevent race conditions. Use the CLI output instead of the HTML viewer when you need to pipe coverage data into a CI dashboard or a linting tool.

Coverage is plumbing. Run it through every long-lived call site.

Where to go next