测试覆盖率 #

测试覆盖率是衡量测试质量的重要指标。Go语言内置了覆盖率工具,可以方便地分析代码被测试覆盖的程度。

基本用法 #

查看覆盖率 #

bash
go test -cover

输出示例:

text
PASS
coverage: 75.0% of statements
ok      myapp    0.002s

详细覆盖率报告 #

bash
go test -coverprofile=coverage.out
go tool cover -func=coverage.out

输出示例:

text
myapp/calculator.go:10:    Add          100.0%
myapp/calculator.go:15:    Subtract     100.0%
myapp/calculator.go:20:    Multiply     50.0%
myapp/calculator.go:25:    Divide       80.0%
total:                     (statements) 82.5%

生成HTML报告 #

bash
go test -coverprofile=coverage.out
go tool cover -html=coverage.out

这会在浏览器中打开一个交互式的覆盖率报告,绿色表示已覆盖,红色表示未覆盖。

保存HTML报告 #

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

代码示例 #

被测代码 #

go
package myapp

import "errors"

func Abs(x int) int {
    if x < 0 {
        return -x
    }
    return x
}

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func Grade(score int) string {
    switch {
    case score >= 90:
        return "A"
    case score >= 80:
        return "B"
    case score >= 70:
        return "C"
    case score >= 60:
        return "D"
    default:
        return "F"
    }
}

func IsEven(n int) bool {
    return n%2 == 0
}

测试代码 #

go
package myapp

import "testing"

func TestAbs(t *testing.T) {
    tests := []struct {
        input    int
        expected int
    }{
        {5, 5},
        {-5, 5},
        {0, 0},
    }
    
    for _, tt := range tests {
        got := Abs(tt.input)
        if got != tt.expected {
            t.Errorf("Abs(%d) = %d; want %d", tt.input, got, tt.expected)
        }
    }
}

func TestDivide(t *testing.T) {
    t.Run("正常除法", func(t *testing.T) {
        got, err := Divide(10, 2)
        if err != nil {
            t.Fatal(err)
        }
        if got != 5 {
            t.Errorf("got %f, want 5", got)
        }
    })
    
    t.Run("除零错误", func(t *testing.T) {
        _, err := Divide(10, 0)
        if err == nil {
            t.Error("expected error")
        }
    })
}

func TestGrade(t *testing.T) {
    tests := []struct {
        score    int
        expected string
    }{
        {95, "A"},
        {85, "B"},
        {75, "C"},
        {65, "D"},
        {55, "F"},
    }
    
    for _, tt := range tests {
        got := Grade(tt.score)
        if got != tt.expected {
            t.Errorf("Grade(%d) = %s; want %s", tt.score, got, tt.expected)
        }
    }
}

分析覆盖率 #

查看未覆盖代码 #

运行HTML报告后,红色高亮显示的代码就是未被测试覆盖的部分。

覆盖率模式 #

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

三种模式:

模式 说明
set 是否执行过(默认)
count 执行次数
atomic 原子计数(并发安全)

提高覆盖率 #

识别未测试分支 #

go
func Process(n int) string {
    if n > 0 {
        return "positive"
    } else if n < 0 {
        return "negative"
    }
    return "zero"
}

初始测试:

go
func TestProcess(t *testing.T) {
    got := Process(5)
    if got != "positive" {
        t.Errorf("got %s, want positive", got)
    }
}

覆盖率:66.7%

完善测试:

go
func TestProcess(t *testing.T) {
    tests := []struct {
        input    int
        expected string
    }{
        {5, "positive"},
        {-5, "negative"},
        {0, "zero"},
    }
    
    for _, tt := range tests {
        got := Process(tt.input)
        if got != tt.expected {
            t.Errorf("Process(%d) = %s; want %s", 
                tt.input, got, tt.expected)
        }
    }
}

覆盖率:100%

测试错误处理 #

go
func ReadConfig(filename string) (*Config, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    
    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, err
    }
    
    return &config, nil
}

测试:

go
func TestReadConfig(t *testing.T) {
    t.Run("文件不存在", func(t *testing.T) {
        _, err := ReadConfig("nonexistent.json")
        if err == nil {
            t.Error("expected error for nonexistent file")
        }
    })
    
    t.Run("无效JSON", func(t *testing.T) {
        tmpfile, _ := os.CreateTemp("", "config*.json")
        defer os.Remove(tmpfile.Name())
        
        tmpfile.WriteString("invalid json")
        tmpfile.Close()
        
        _, err := ReadConfig(tmpfile.Name())
        if err == nil {
            t.Error("expected error for invalid JSON")
        }
    })
    
    t.Run("有效配置", func(t *testing.T) {
        tmpfile, _ := os.CreateTemp("", "config*.json")
        defer os.Remove(tmpfile.Name())
        
        tmpfile.WriteString(`{"name": "test", "port": 8080}`)
        tmpfile.Close()
        
        config, err := ReadConfig(tmpfile.Name())
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        
        if config.Name != "test" {
            t.Errorf("got name %s, want test", config.Name)
        }
    })
}

覆盖率目标 #

设置覆盖率阈值 #

bash
go test -cover -coverprofile=coverage.out
go tool cover -func=coverage.out | grep total | awk '{print $3}'

Makefile 集成 #

makefile
test:
    go test -v -coverprofile=coverage.out ./...
    go tool cover -html=coverage.out -o coverage.html

coverage:
    go test -cover -coverprofile=coverage.out ./...
    @coverage=$$(go tool cover -func=coverage.out | grep total | awk '{print $$3}' | sed 's/%//'); \
    if [ $$(echo "$$coverage < 80" | bc) -eq 1 ]; then \
        echo "Coverage $$coverage% is below 80%"; \
        exit 1; \
    fi

多包覆盖率 #

bash
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

合并多个覆盖率文件 #

bash
go test -coverprofile=coverage1.out ./pkg1
go test -coverprofile=coverage2.out ./pkg2

gocovmerge coverage1.out coverage2.out > coverage.out

CI/CD 集成 #

GitHub Actions 示例 #

yaml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-go@v4
        with:
          go-version: '1.21'
      
      - name: Run tests
        run: go test -v -coverprofile=coverage.out ./...
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: coverage.out

覆盖率报告解读 #

覆盖率不是唯一指标 #

高覆盖率不等于高质量测试:

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

func TestAdd(t *testing.T) {
    Add(1, 2)
}

这个测试覆盖率是100%,但没有验证结果。

有效测试 #

go
func TestAdd(t *testing.T) {
    tests := []struct {
        a, b, expected int
    }{
        {1, 2, 3},
        {-1, 1, 0},
        {0, 0, 0},
    }
    
    for _, tt := range tests {
        got := Add(tt.a, tt.b)
        if got != tt.expected {
            t.Errorf("Add(%d, %d) = %d; want %d",
                tt.a, tt.b, got, tt.expected)
        }
    }
}

小结 #

命令 说明
go test -cover 显示覆盖率百分比
go test -coverprofile 生成覆盖率报告文件
go tool cover -func 显示函数级覆盖率
go tool cover -html 生成HTML报告

测试覆盖率是衡量测试质量的重要指标,但不是唯一指标。追求高覆盖率的同时,更要关注测试的有效性和边界条件的覆盖。