Understanding and Measuring Test Coverage in Go
Go provides built-in support for measuring test coverage. This guide covers how to use coverage tools and improve your test coverage effectively.
Basic Coverage
Running Tests with Coverage
# Run tests with coverage
go test -cover
# Generate coverage profile
go test -coverprofile=coverage.out
# View coverage in browser
go tool cover -html=coverage.out
# View coverage in terminal
go tool cover -func=coverage.out
Coverage Profile Output
$ go tool cover -func=coverage.out
github.com/example/pkg/file.go:10: Function1 100.0%
github.com/example/pkg/file.go:20: Function2 75.0%
github.com/example/pkg/file.go:30: Function3 0.0%
total: (statements) 82.5%
Advanced Coverage
Per-Package Coverage
// main_test.go
func TestMain(m *testing.M) {
// Setup code
// Run tests with coverage
c := testing.Coverage()
if c < 0.80 {
fmt.Printf("Tests passed but coverage %.2f%% is below 80%%\n", c*100)
os.Exit(1)
}
os.Exit(m.Run())
}
Coverage Tags
// +build coverage
package main
func init() {
// Coverage-specific initialization
}
// Regular code...
Coverage Tools
Using go-test-coverage
# Install go-test-coverage
go install github.com/vladopajic/go-test-coverage@latest
# Configure coverage check
cat > .testcoverage.yml << EOF
coverage:
statements: 80
branches: 70
functions: 60
lines: 80
EOF
# Run coverage check
go-test-coverage
Coverage Reports
package main
import (
"testing"
"os"
"fmt"
)
func TestWithCoverage(t *testing.T) {
if os.Getenv("GENERATE_COVERAGE") == "1" {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Test panicked: %v\n", r)
}
}()
}
// Test code...
}
Best Practices
1. Set Coverage Targets
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.21'
- name: Run Tests
run: |
go test -coverprofile=coverage.out ./...
- name: Check Coverage
run: |
coverage=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "Coverage $coverage% is below target of 80%"
exit 1
fi
2. Exclude Generated Code
//go:generate go run generate.go
//go:build !coverage
package main
// Generated code here...
3. Focus on Critical Paths
func TestCriticalPath(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
// Core functionality
{"basic case", "input", "output", false},
// Edge cases
{"empty input", "", "", true},
{"invalid input", "invalid", "", true},
// Boundary conditions
{"max length", strings.Repeat("a", maxLen), "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Process(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Process() = %v, want %v", got, tt.want)
}
})
}
}
Coverage Analysis
Statement Coverage
func ExampleFunction() {
// This line is covered
fmt.Println("Hello")
if someCondition {
// This might not be covered
fmt.Println("Conditional")
}
}
func TestExampleFunction(t *testing.T) {
ExampleFunction() // Only covers the first line
}
Branch Coverage
func ProcessValue(val int) string {
if val < 0 {
return "negative"
} else if val == 0 {
return "zero"
} else {
return "positive"
}
}
func TestProcessValue(t *testing.T) {
tests := []struct {
name string
val int
want string
}{
{"negative", -1, "negative"},
{"zero", 0, "zero"},
{"positive", 1, "positive"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ProcessValue(tt.val); got != tt.want {
t.Errorf("ProcessValue() = %v, want %v", got, tt.want)
}
})
}
}
Function Coverage
type Service struct {
db Database
}
func (s *Service) ProcessItem(item *Item) error {
// Coverage should include error paths
if err := s.validate(item); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
if err := s.db.Save(item); err != nil {
return fmt.Errorf("save failed: %w", err)
}
return nil
}
func TestService_ProcessItem(t *testing.T) {
tests := []struct {
name string
item *Item
dbErr error
wantErr bool
}{
{
name: "success",
item: &Item{Valid: true},
wantErr: false,
},
{
name: "invalid item",
item: &Item{Valid: false},
wantErr: true,
},
{
name: "db error",
item: &Item{Valid: true},
dbErr: errors.New("db error"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockDB := &MockDB{err: tt.dbErr}
s := &Service{db: mockDB}
err := s.ProcessItem(tt.item)
if (err != nil) != tt.wantErr {
t.Errorf("ProcessItem() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
Continuous Integration
GitHub Actions Example
name: Test Coverage
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: '1.21'
- name: Run Tests
run: |
go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
file: ./coverage.out
fail_ci_if_error: true
Next Steps
- Learn about Integration Tests
- Explore HTTP Testing
- Study Benchmarking