← 中文资源

简体中文版本

测试覆盖率指标

测试覆盖率指标量化测试套件执行了代码的多大比例,作为信号有用,作为目标却危险。

阅读英文原文

概念定义

测试覆盖率衡量测试所触及代码的比例:行覆盖率(被执行的行)、分支覆盖率(被走到的判定结果)、路径覆盖率(被执行的控制流路径)。它通过插桩执行并记录哪些单元运行过来计算。覆盖率能揭示未被测试的区域,却无法说明断言是否有意义,因此它是盲区地图,而不是质量度量。

为什么重要

覆盖率是廉价、自动地找出“无人测试代码”的手段——那些变更可以在没有安全网的情况下上线的模块。这样使用时,它能精确引导测试投入。而当作分数使用时就会反噬:开发者写出触及代码行却不断言行为的测试,指标上升而套件什么也没保护。被执行与被验证之间的缺口,正是缺陷藏身之处。

工程实践

用覆盖率找出未测试区域并加以审查,而不是庆祝一个数字。设置一个防止回退的适度下限,并优先采用“差异覆盖率”——只衡量实际改动的代码——作为合并门禁,因为它随变更而非仓库规模伸缩。当需要证明测试确实检查行为时,把它与变异测试或断言审查结合使用。

代码示例

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)
	}
}