Skip to main content

Code Coverage and Quality Metrics in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Go ships a coverage tool built into go test. No separate installation, no configuration files for basic use — just a flag. Understanding what coverage measures, how to interpret it, and how to enforce it in CI separates a mature Go project from one that’s just hoping the tests are good enough.

For testing fundamentals see Go testing basics.

Running Coverage

The simplest form shows a percentage per package:

go test -cover ./...
# ok   myapp/handlers    coverage: 87.3% of statements
# ok   myapp/service     coverage: 92.1% of statements
# ok   myapp/repository  coverage: 76.4% of statements

For a deeper look, generate a coverage profile — a file recording which statements were executed:

# Write profile for all packages
go test -coverprofile=coverage.out ./...

# Merge profiles from multiple runs (useful for integration tests)
go test -coverprofile=unit.out ./...
go test -run Integration -coverprofile=integration.out ./...

Analyzing Coverage

Per-function breakdown — which functions have low coverage:

go tool cover -func=coverage.out
# myapp/handlers/users.go:handleGetUser    87.5%
# myapp/handlers/users.go:handleCreateUser 100.0%
# myapp/service/order.go:PlaceOrder        62.5%   ← low coverage
# total:                                    83.2%

HTML report — visual heat map of covered (green) vs uncovered (red) lines:

go tool cover -html=coverage.out
# Opens browser with annotated source files

The HTML report is the most useful for understanding what isn’t covered — you can see whether the uncovered lines are error handling paths, rarely-used features, or core business logic.

Coverage Modes

Three modes control what the coverage tool tracks:

  • -covermode=set (default): was this statement executed? (yes/no)
  • -covermode=count: how many times was this statement executed?
  • -covermode=atomic: like count but safe for concurrent tests (slightly slower)

Use count or atomic when you want to identify “hot” code paths — statements executed thousands of times — vs cold paths that might need more stress testing:

go test -covermode=count -coverprofile=coverage.out ./...
go tool cover -html=coverage.out  # darker green = more executions

Enforcing Coverage in CI

A common CI gate: fail the build if total coverage drops below a threshold. Parse the total from go tool cover -func:

# Get total coverage percentage
COVERAGE=$(go test -coverprofile=coverage.out ./... && \
  go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')

# Fail if below 80%
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
  echo "Coverage $COVERAGE% is below threshold of 80%"
  exit 1
fi
echo "Coverage: $COVERAGE%"

A more structured approach uses go-coverage-report:

# .github/workflows/test.yml
- name: Run tests with coverage
  run: go test -coverprofile=coverage.out -covermode=atomic ./...

- name: Check coverage threshold
  run: |
    TOTAL=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
    echo "Total coverage: $TOTAL%"
    awk -v threshold=80 'BEGIN{ if ('$TOTAL' < threshold) { 
      print "Coverage "$TOTAL"% below threshold "threshold"%"; exit 1 
    }}'

- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v4
  with:
    file: coverage.out

What Coverage Doesn’t Measure

Coverage percentage is a leading indicator, not a quality measure. 100% statement coverage doesn’t mean:

  • All code paths through complex conditions are tested
  • Edge cases are covered (empty slices, nil values, negative numbers)
  • The tests verify correct behavior — only that the lines were executed
  • Error paths were tested to completion

A function with if err != nil { return err } is “covered” if it enters that branch, but coverage can’t tell you if err was actually non-nil or if the test mocked it away. Good coverage means testing the paths that matter, not maximizing the percentage.

A useful mental model: coverage finds code your tests never touch. Fixing coverage means looking at what’s uncovered and deciding: should this be tested? If yes, write the test. If the code is dead code, delete it.

golangci-lint: Static Analysis for Quality

Test coverage measures runtime behavior; static analysis catches structural issues before tests run. golangci-lint runs dozens of linters in one command:

go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run ./...

A minimal .golangci.yml that catches the most impactful issues:

# .golangci.yml
linters:
  enable:
    - gofmt         # formatting
    - govet         # go vet checks
    - errcheck      # unhandled errors
    - staticcheck   # comprehensive static analysis
    - gosimple      # code simplification suggestions
    - unused        # unused code
    - gosec         # security issues
    - misspell      # typos in comments/strings
    - noctx         # HTTP calls without context

linters-settings:
  errcheck:
    check-type-assertions: true  # flag unchecked type assertions
  gosec:
    excludes: [G104]             # G104 = unhandled errors (errcheck covers this)

issues:
  exclude-rules:
    - path: _test\.go
      linters: [errcheck]        # allow unchecked errors in test files
    - linters: [staticcheck]
      text: "SA1019"             # allow deprecated API usage (for now)

Run it in CI:

- name: Run golangci-lint
  uses: golangci/golangci-lint-action@v6
  with:
    version: latest

go vet: The Built-In Checker

go vet catches a narrower but high-confidence set of issues — things that are almost certainly bugs:

go vet ./...

Common things go vet catches:

  • printf-style format string mismatches (%d with a string argument)
  • Calling sync.Mutex by value (should be by pointer)
  • Unreachable code after return
  • Suspicious struct tags (json:"name," instead of json:"name,omitempty")
  • Tests that call t.Fatal from goroutines (invalid)

go vet is part of the standard toolchain and runs automatically with go test -vet=all (the default). It’s fast and has no false positives by design — if it flags something, it’s worth fixing.

CI Pipeline Summary

A complete quality gate for pull requests:

# Format check
gofmt -l . | grep -q . && echo "formatting issues found" && exit 1

# Vet (also runs as part of go test, but explicit for visibility)
go vet ./...

# Tests with race detector and coverage
go test -race -coverprofile=coverage.out -covermode=atomic ./...

# Coverage threshold
COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | tr -d '%')
[ "$(echo "$COVERAGE < 75" | bc)" -eq 1 ] && echo "Coverage $COVERAGE% below 75%" && exit 1

# Linting
golangci-lint run ./...

# Vulnerability check
govulncheck ./...

echo "All checks passed — coverage: $COVERAGE%"

Summary

  • go test -coverprofile=coverage.out ./... generates the coverage data; go tool cover analyzes it
  • -covermode=count shows execution frequency; -covermode=atomic is safe for parallel tests
  • HTML report (go tool cover -html) is the most actionable view — shows exactly which lines aren’t exercised
  • Enforce a threshold in CI by parsing go tool cover -func total line; 75–80% is a reasonable floor for most services
  • Coverage measures which lines ran, not whether they were tested correctly — high coverage with poor assertions is still poor quality
  • golangci-lint with errcheck, staticcheck, and gosec catches structural issues that tests miss

Resources

Comments

👍 Was this article helpful?