Go Test Coverage: Measuring Code Coverage

Code coverage measures how much of your source code executes during testing. It's a diagnostic tool, not a quality guarantee. A function with 100% coverage can still have bugs if your tests don't...

Key Insights

  • Go’s built-in coverage tooling provides everything you need to measure and visualize test coverage without external dependencies—use go test -cover for quick checks and go tool cover -html for detailed analysis.
  • The three coverage modes (set, count, atomic) serve different purposes: use set for basic coverage, count for identifying hot paths, and atomic for concurrent code.
  • Coverage percentages are a useful metric but not a goal—focus on covering critical paths and edge cases rather than chasing arbitrary thresholds.

Why Code Coverage Matters

Code coverage measures how much of your source code executes during testing. It’s a diagnostic tool, not a quality guarantee. A function with 100% coverage can still have bugs if your tests don’t verify correct behavior. That said, code with 0% coverage definitely has no automated verification.

Go ships with coverage tooling built into the standard toolchain. No external dependencies, no configuration files, no third-party services required. This is a deliberate design choice—the Go team wants testing and coverage to be first-class citizens, not afterthoughts.

Let’s explore how to use these tools effectively.

Basic Coverage Commands

The simplest way to check coverage is adding the -cover flag to your test command:

go test -cover ./...

This outputs a coverage percentage for each package. Let’s see it in action with a simple example.

// calculator.go
package calculator

func Add(a, b int) int {
    return a + b
}

func Divide(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

func Multiply(a, b int) int {
    return a * b
}
// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("expected 5, got %d", result)
    }
}

func TestDivide(t *testing.T) {
    result, err := Divide(10, 2)
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if result != 5 {
        t.Errorf("expected 5, got %d", result)
    }
}

Running go test -cover produces:

PASS
coverage: 66.7% of statements
ok      calculator    0.002s

We’re missing coverage for the Multiply function and the division-by-zero error path. The percentage tells us something is untested, but not what.

For detailed analysis, generate a coverage profile:

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

This creates a file containing line-by-line coverage data that other tools can consume.

Generating Coverage Reports

The coverage profile alone isn’t human-readable. Convert it to an HTML report:

go tool cover -html=coverage.out -o coverage.html

Open coverage.html in your browser. You’ll see your source code with color-coded highlighting:

  • Green: Lines executed during tests
  • Red: Lines never executed
  • Gray: Lines that aren’t executable (comments, declarations)

This visualization immediately shows that Multiply is entirely red and the if b == 0 branch in Divide is red. Now you know exactly what to test next.

For quick terminal output without opening a browser, use the -func flag:

go tool cover -func=coverage.out

This produces a function-by-function breakdown:

calculator/calculator.go:5:     Add         100.0%
calculator/calculator.go:9:     Divide      66.7%
calculator/calculator.go:16:    Multiply    0.0%
total:                          (statements) 66.7%

Coverage Modes: set, count, and atomic

Go supports three coverage modes, specified with -covermode:

set (default): Records whether each statement executed at all. Binary yes/no. Fastest and sufficient for most use cases.

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

count: Records how many times each statement executed. Useful for identifying hot paths and understanding which code paths your tests exercise most heavily.

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

atomic: Like count, but uses atomic operations for accuracy with concurrent code. Required when testing code with goroutines to avoid race conditions in the coverage instrumentation itself.

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

With count mode, the HTML report becomes a heat map. Statements executed many times appear in bright green, while statements executed once or twice appear in a muted green. This reveals which paths your tests emphasize.

Here’s when to use each:

  • set: Default choice. Fast, simple, answers “is this tested?”
  • count: Profiling test thoroughness, identifying undertested happy paths
  • atomic: Any code using goroutines, channels, or concurrent patterns

The performance difference matters at scale. set mode adds minimal overhead. atomic mode can slow tests noticeably in hot loops.

Package-Level and Multi-Package Coverage

By default, Go only measures coverage for the package being tested. If your api package calls functions in your storage package, those storage functions won’t appear in the coverage report when running api tests.

Use -coverpkg to measure coverage across packages:

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

This measures coverage across all packages in your module, regardless of which package’s tests exercise the code.

For more targeted measurement:

go test -coverprofile=coverage.out -coverpkg=./internal/storage,./internal/cache ./...

Consider this project structure:

myapp/
├── cmd/
│   └── server/
├── internal/
│   ├── api/
│   ├── storage/
│   └── cache/
└── pkg/
    └── client/

Running integration tests in cmd/server might exercise code across all internal packages. Without -coverpkg, you’d only see coverage for cmd/server itself (probably minimal, since it’s mostly wiring). With -coverpkg=./..., you see the actual coverage your integration tests provide.

To merge coverage from multiple test runs:

go test -coverprofile=unit.out ./internal/...
go test -coverprofile=integration.out -coverpkg=./... ./cmd/...

# Merge profiles (requires go 1.20+)
go tool covdata merge -i=unit.out,integration.out -o=merged.out

Integrating Coverage in CI/CD

Coverage becomes most valuable when tracked over time. Here’s a GitHub Actions workflow that runs tests with coverage and fails if coverage drops below a threshold:

name: Test with Coverage

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      
      - name: Run tests with coverage
        run: go test -coverprofile=coverage.out -covermode=atomic ./...
      
      - name: Check coverage threshold
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print substr($3, 1, length($3)-1)}')
          echo "Total coverage: ${COVERAGE}%"
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "Coverage ${COVERAGE}% is below threshold of 70%"
            exit 1
          fi          
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.out
          fail_ci_if_error: true

For Codecov or Coveralls integration, both services accept Go’s native coverage format. No conversion needed.

A more sophisticated approach tracks coverage trends rather than absolute thresholds:

- name: Compare coverage to main branch
  run: |
    git fetch origin main
    git checkout origin/main -- coverage.out || echo "0" > base_coverage.txt
    # Compare and fail if coverage decreased by more than 2%    

This prevents coverage regression without mandating arbitrary minimums.

Coverage Best Practices and Limitations

Set reasonable thresholds. 80% coverage is achievable for most business logic. 90%+ often requires testing generated code, trivial getters, or error paths that are difficult to trigger. 100% is usually a waste of time and can encourage bad tests written purely to hit lines.

Exclude generated code. Add build tags or use .coverignore patterns to skip generated protocol buffers, mocks, or other machine-written code:

go test -coverprofile=coverage.out ./... -coverpkg=./... | grep -v "_generated.go"

Coverage doesn’t measure test quality. This function has 100% coverage with a useless test:

func TestDivide_Useless(t *testing.T) {
    Divide(10, 2)  // No assertions!
}

The code executed, but nothing verified correctness.

Focus on critical paths. Payment processing, authentication, and data validation deserve thorough coverage. Admin dashboard formatting can be lower priority.

Don’t game the metric. Tests written solely to increase coverage percentages often test implementation details, making refactoring painful. If you’re writing a test and thinking “this is pointless but it’ll bump coverage,” reconsider.

Coverage misses logical gaps. If your Divide function should reject negative numbers but doesn’t, coverage won’t catch that. The missing code has no lines to cover.

Use coverage as a discovery tool. The HTML report’s red sections often reveal forgotten error handling, abandoned code paths, or features that lack any testing. That’s more valuable than the percentage.

Coverage is a tool for finding untested code, not a measure of software quality. Use it to guide your testing efforts, integrate it into CI to prevent regression, and resist the temptation to treat the number as a goal. A well-tested codebase with 75% coverage beats a poorly-tested one at 95%.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.