测试覆盖率 #
测试覆盖率是衡量测试质量的重要指标。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报告 |
测试覆盖率是衡量测试质量的重要指标,但不是唯一指标。追求高覆盖率的同时,更要关注测试的有效性和边界条件的覆盖。