概念定义
测试覆盖率衡量测试所触及代码的比例:行覆盖率(被执行的行)、分支覆盖率(被走到的判定结果)、路径覆盖率(被执行的控制流路径)。它通过插桩执行并记录哪些单元运行过来计算。覆盖率能揭示未被测试的区域,却无法说明断言是否有意义,因此它是盲区地图,而不是质量度量。
为什么重要
覆盖率是廉价、自动地找出“无人测试代码”的手段——那些变更可以在没有安全网的情况下上线的模块。这样使用时,它能精确引导测试投入。而当作分数使用时就会反噬:开发者写出触及代码行却不断言行为的测试,指标上升而套件什么也没保护。被执行与被验证之间的缺口,正是缺陷藏身之处。
工程实践
用覆盖率找出未测试区域并加以审查,而不是庆祝一个数字。设置一个防止回退的适度下限,并优先采用“差异覆盖率”——只衡量实际改动的代码——作为合并门禁,因为它随变更而非仓库规模伸缩。当需要证明测试确实检查行为时,把它与变异测试或断言审查结合使用。
代码示例
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type Counters struct {
requests atomic.Int64
errors atomic.Int64
bytes atomic.Int64
}
func (c *Counters) IncRequest() { c.requests.Add(1) }
func (c *Counters) IncError() { c.errors.Add(1) }
func (c *Counters) AddBytes(n int64) { c.bytes.Add(n) }
func (c *Counters) Snapshot() (req, errs, transferred int64) {
return c.requests.Load(), c.errors.Load(), c.bytes.Load()
}
type RateCalculator struct {
lastValue atomic.Int64
lastTime atomic.Int64
}
func (r *RateCalculator) Rate(now time.Time, current int64) float64 {
prevTime := r.lastTime.Load()
prevValue := r.lastValue.Load()
if prevTime == 0 {
r.lastTime.Store(now.Unix())
r.lastValue.Store(current)
return 0
}
elapsed := now.Unix() - prevTime
if elapsed <= 0 {
return 0
}
rate := float64(current-prevValue) / float64(elapsed)
r.lastTime.Store(now.Unix())
r.lastValue.Store(current)
return rate
}
func main() {
counters := new(Counters)
rate := new(RateCalculator)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
counters.IncRequest()
if n%50 == 0 {
counters.IncError() // 模拟约百分之二的错误率
}
counters.AddBytes(512)
}(i)
}
wg.Wait()
req, errs, transferred := counters.Snapshot()
fmt.Println(req, errs, transferred, rate.Rate(time.Now(), req))
}
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
func WriteGolden(target string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("create golden dir: %w", err)
}
return os.WriteFile(target, data, 0o644)
}
func diffLines(want, got string) string {
wantLines := strings.Split(strings.TrimSpace(want), "\n")
gotLines := strings.Split(strings.TrimSpace(got), "\n")
limit := len(wantLines)
if len(gotLines) < limit {
limit = len(gotLines)
}
var builder strings.Builder
for i := 0; i < limit; i++ {
if wantLines[i] != gotLines[i] {
fmt.Fprintf(&builder, "- %s\n+ %s\n", wantLines[i], gotLines[i])
}
}
if len(wantLines) != len(gotLines) {
fmt.Fprintf(&builder, "line count: want %d got %d\n", len(wantLines), len(gotLines))
}
return builder.String()
}
func CompareGolden(target string, got []byte) error {
want, err := os.ReadFile(target)
if err != nil {
return fmt.Errorf("read golden: %w", err)
}
if !bytes.Equal(bytes.TrimSpace(got), bytes.TrimSpace(want)) {
return fmt.Errorf("golden mismatch for %s\n%s", filepath.Base(target), diffLines(string(want), string(got)))
}
return nil
}
func main() {
dir, err := os.MkdirTemp("", "golden-*")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
target := filepath.Join(dir, "report.golden")
report := []byte("users=3\norders=5\n")
if err := WriteGolden(target, report); err != nil {
panic(err)
}
if err := CompareGolden(target, report); err != nil {
fmt.Println("unexpected mismatch:", err)
} else {
fmt.Println("match: ok")
}
if err := CompareGolden(target, []byte("users=4\norders=5\n")); err != nil {
fmt.Println("mismatch detected:")
fmt.Println(err)
}
}