Skip to content

测试最佳实践与覆盖率

经过前 7 篇的学习,我们已经掌握了 Go 测试的几乎全部技术细节:testing 包、testify、Mock、HTTP 测试、基准测试、Fuzz、集成测试、测试容器。最后这一篇不再讲新工具,而是聚焦工程化层面——如何组织测试文件、避免坏味道、设定合理的覆盖率目标、实践 TDD/BDD、把测试融入 CI/CD 流水线。这些「软技能」往往决定了一个项目测试体系的成败。

一、测试文件组织:与源文件同目录

Go 的官方惯例是「测试文件与源文件同目录、同名加 _test 后缀」。例如:

text
user/
├── user.go           ← 源文件
├── user_test.go      ← 测试文件
├── service.go
├── service_test.go
└── mock_repository.go ← 由 mockery 生成的 Mock

这种组织的优点:

  1. 物理邻近:测试与被测代码在同一个目录,go test ./... 自动发现。
  2. 可读性:开发者看 user.go 时,旁边就是 user_test.go,验证逻辑随手可查。
  3. 包内访问:测试文件可以与源文件同包(package user),访问未导出成员。

对比其他语言的「独立 tests 目录」:

text
# ❌ 不推荐:测试与源分离
src/
└── user/
    └── user.go
tests/
└── user/
    └── user_test.go   ← Go 工具链不会自动发现

Go 不鼓励这种结构。go test 只扫描每个包目录下的 _test.go 文件,独立 tests 目录会被忽略,除非显式配置。

子目录组织大量测试

如果一个包的测试非常多,可以拆分到子目录:

text
user/
├── user.go
├── user_test.go        ← 核心测试
├── service.go
├── service_test.go
├── mock_repository.go
└── integration/        ← 集成测试单独目录
    ├── integration_test.go
    └── helpers.go

但要注意,子目录会成为独立的包,需要单独 import 父包,无法访问未导出成员。

testdata 目录

Go 工具链会自动忽略 testdata/ 目录,但 go test 可以读取里面的文件作为测试输入:

text
parser/
├── parser.go
├── parser_test.go
└── testdata/
    ├── input1.json
    ├── input2.json
    └── golden/
        └── expected1.json

代码中:

go
data, err := os.ReadFile("testdata/input1.json")

go test 会把当前工作目录设为包目录,所以相对路径 testdata/xxx 可以直接使用。

二、内部测试 vs 外部测试

Go 测试文件可以放在两个不同的包中:

内部测试:与源文件同包

go
// user.go
package user

type User struct {
	id   int    // 未导出
	Name string
}

func (u *User) ID() int { return u.id }
go
// user_test.go
package user  // ← 与源文件同包

import "testing"

func TestUser_UnexportedField(t *testing.T) {
	u := &User{id: 1, Name: "Alice"} // 可以访问 id
	if u.id != 1 {
		t.Error("id should be 1")
	}
}

优点:

  • 可以访问未导出字段与方法。
  • 适合测试内部细节。

缺点:

  • 测试与实现耦合,重构时易碎。
  • 容易写成「测实现」而非「测行为」。

外部测试:独立包,加 _test 后缀

go
// user_test.go
package user_test  // ← 独立包

import (
	"testing"
	"example.com/user"
)

func TestUser_PublicAPI(t *testing.T) {
	u := user.NewUser("Alice") // 只能用导出的 API
	if u.Name != "Alice" {
		t.Error("name mismatch")
	}
}

优点:

  • 只能访问导出 API,与外部调用方一致。
  • 强制测试「公开契约」,重构时不易碎。
  • 包导入循环可以打破(测试包可以导入被测包与第三方包)。

缺点:

  • 不能测未导出细节。

选择策略

Go 团队推荐:优先用外部测试包,仅在必要时(如测试未导出辅助函数)用内部测试。

实践建议:

  • 80% 测试用 package xxx_test,验证公开 API。
  • 20% 测试用 package xxx,验证内部实现细节。
  • 一个文件不要混用两种包声明——Go 不允许同一个文件里有两个 package 声明。

三、测试命名规范

测试命名直接决定测试输出的可读性。一个推荐的规范:

顶层测试函数

Test{Subject}_{Condition}_{ExpectedResult}

例如:

go
func TestUser_Validate_ValidUser(t *testing.T)
func TestUser_Validate_EmptyName_ReturnsError(t *testing.T)
func TestUserService_Create_DuplicateEmail_ReturnsError(t *testing.T)
func TestHTTPServer_RateLimit_TooManyRequests_Returns429(t *testing.T)

要点:

  • 用下划线分隔层级(Subject、Condition、ExpectedResult)。
  • Subject 是被测对象(类型或方法)。
  • Condition 是测试场景。
  • ExpectedResult 是期望结果。
  • 避免模糊名称如 Test1TestHandleTestSomething

子测试

子测试用 t.Run("name", ...),名字应该简短描述场景:

go
t.Run("valid input", ...)
t.Run("empty name", ...)
t.Run("too long name", ...)
t.Run("special characters", ...)

避免:

go
t.Run("case1", ...)        // 含义不明
t.Run("test1", ...)        // 冗余
t.Run("ValidateUser", ...) // 与父级 TestUser_Validate 重复

基准测试

Benchmark{Subject}_{Condition}
go
func BenchmarkJoin_Std(b *testing.B)
func BenchmarkJoin_Builder(b *testing.B)
func BenchmarkToUpper_Parallel(b *testing.B)

表驱动测试用例

go
cases := []struct {
    name  string
    input string
    want  int
}{
    {"empty", "", 0},
    {"single char", "a", 1},
    {"long string", "abcdefg", 7},
}

name 字段会作为子测试名,要表意清晰。

四、测试代码的坏味道

写测试也会积累「技术债」。下面是几种常见的测试坏味道。

1. 测试过多断言

go
// ❌ 一个测试塞了 20 个断言,定位失败困难
func TestUser(t *testing.T) {
	u := getUser()
	assert.Equal(t, 1, u.ID)
	assert.Equal(t, "Alice", u.Name)
	assert.Equal(t, "alice@example.com", u.Email)
	assert.Equal(t, 28, u.Age)
	assert.Equal(t, "admin", u.Role)
	// ... 15 more asserts
}

问题:失败时难以定位是哪个断言挂了,且一个失败可能让后续断言不执行(如果是 require)。

修复:拆成多个子测试,每个聚焦一个属性:

go
func TestUser(t *testing.T) {
	u := getUser()
	t.Run("has valid ID", func(t *testing.T) {
		assert.NotZero(t, u.ID)
	})
	t.Run("has correct name", func(t *testing.T) {
		assert.Equal(t, "Alice", u.Name)
	})
	t.Run("has correct email", func(t *testing.T) {
		assert.Equal(t, "alice@example.com", u.Email)
	})
}

2. 测试依赖执行顺序

go
var createdUserID int

func TestCreateUser(t *testing.T) {
	createdUserID = createUser().ID
}

func TestGetUser(t *testing.T) {
	// 依赖 TestCreateUser 先执行并设置 createdUserID
	u := getUser(createdUserID)
	// ...
}

问题:Go 不保证测试执行顺序,CI 上跑顺序可能不同,导致偶发失败。

修复:每个测试自包含,或用 TestMain 共享 setup:

go
func TestGetUser(t *testing.T) {
	u := createUser() // 自己创建
	defer deleteUser(u.ID)
	got := getUser(u.ID)
	// ...
}

3. 测试不可重复

go
// ❌ 依赖时间,每次跑结果不同
func TestTimeSensitive(t *testing.T) {
	now := time.Now()
	if !isRecent(now, 1*time.Second) {
		t.Error("should be recent")
	}
}
go
// ❌ 依赖随机数
func TestRandom(t *testing.T) {
	r := rand.Intn(100)
	if r < 0 || r >= 100 {
		t.Error("out of range")
	}
}

修复:注入时间或随机源,让测试可控:

go
func TestTimeSensitive(t *testing.T) {
	fakeNow := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
	if !isRecentWithNow(fakeNow, fakeNow, 1*time.Second) {
		t.Error("should be recent")
	}
}

4. 测试代码与生产代码重复

go
// ❌ 测试代码重新实现了被测逻辑
func TestSum(t *testing.T) {
	input := []int{1, 2, 3, 4, 5}
	want := 0
	for _, n := range input {
		want += n
	}
	got := Sum(input)
	assert.Equal(t, want, got)
}

如果 Sum 算错了,测试里的循环也跟着错——两边都是同样的逻辑。

修复:直接用字面量:

go
func TestSum(t *testing.T) {
	got := Sum([]int{1, 2, 3, 4, 5})
	assert.Equal(t, 15, got) // 期望值是计算出来的字面量
}

5. 测试断言过深

go
// ❌ 断言嵌套结构体的每个字段
func TestResponse(t *testing.T) {
	resp := doRequest()
	assert.Equal(t, 200, resp.Code)
	assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
	var body map[string]interface{}
	json.Unmarshal(resp.Body.Bytes(), &body)
	assert.Equal(t, "Alice", body["user"].(map[string]interface{})["name"].(string))
	assert.Equal(t, "alice@example.com", body["user"].(map[string]interface{})["email"].(string))
}

修复:定义结构体类型,一次解码后比较:

go
type expectedBody struct {
	User struct {
		Name  string `json:"name"`
		Email string `json:"email"`
	} `json:"user"`
}

func TestResponse(t *testing.T) {
	resp := doRequest()
	require.Equal(t, 200, resp.Code)

	var body expectedBody
	require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &body))
	assert.Equal(t, "Alice", body.User.Name)
	assert.Equal(t, "alice@example.com", body.User.Email)
}

6. 测试过度依赖 Mock 内部实现

go
// ❌ 测试「Service 调用 Repo.FindByID 一次」,过度耦合实现
mockRepo.EXPECT().FindByID(1).Times(1)
svc.Get(1)

如果将来优化成缓存,FindByID 不会被调用——测试就崩了。但行为没变,测试失败没有意义。

修复:只在「调用次数是契约一部分」时才断言次数。一般用 AnyTimes() 让测试更鲁棒。

7. 测试不清理副作用

go
// ❌ 测试创建了文件但没删除
func TestWriteFile(t *testing.T) {
	_ = os.WriteFile("/tmp/test.txt", []byte("hi"), 0644)
	// 测试结束后 /tmp/test.txt 还在
}

修复:用 t.Cleanupdefer

go
func TestWriteFile(t *testing.T) {
	path := "/tmp/test.txt"
	t.Cleanup(func() { _ = os.Remove(path) })
	_ = os.WriteFile(path, []byte("hi"), 0644)
}

t.Cleanup 会在测试(含子测试)结束后自动执行注册的清理函数,按 LIFO 顺序,比 defer 更适合测试场景。

五、测试覆盖率深入

1. go test -coverprofile

bash
go test -coverprofile=coverage.out ./...

-coverprofile 生成详细的覆盖率数据文件。./... 让所有包都被测量。

2. go tool cover

bash
# 在终端查看每个函数的覆盖率
go tool cover -func=coverage.out

# 生成 HTML 报告(推荐)
go tool cover -html=coverage.out -o coverage.html

# 直接打开浏览器
go tool cover -html=coverage.out

HTML 报告中:

  • 绿色:被覆盖的行。
  • 红色:未覆盖的行。
  • 黄色:部分覆盖(如 switch 分支)。

函数级报告示例:

text
github.com/example/user/service.go:18:     Get                  90.0%
github.com/example/user/service.go:42:     Create               100.0%
github.com/example/user/service.go:78:     Update               60.0%
github.com/example/user/service.go:120:    Delete               100.0%
total:                                                          87.5%

Update 只有 60%,说明有分支没被测试。打开 HTML 报告可以定位到具体哪一行没执行。

3. 多包覆盖率合并

bash
# 测试多个包,合并到一个文件
go test -coverprofile=coverage.out -coverpkg=./... ./...

-coverpkg=./... 让所有包都被纳入覆盖率统计,即使测试是在另一个包里跑的。

4. 覆盖率模式

Go 支持三种覆盖率模式:

  • set(默认):每行是否被覆盖(boolean)。
  • count:每行被覆盖多少次。
  • atomic:并发安全版本的 count,适合并行测试。
bash
go test -coverprofile=coverage.out -covermode=atomic ./...

atomic 提供最详细的信息,是推荐用法。

5. 覆盖率目标设定

覆盖率应该设多高?没有标准答案,但有一些经验值:

项目类型推荐目标理由
库 / SDK85-95%被外部依赖,必须稳健
业务 Web 应用60-80%业务变化快,过度测试拖累迭代
工具脚本30-50%验证关键路径即可
实验性项目不强制视情况而定

不推荐 100% 覆盖率作为硬指标

  • 100% 覆盖率只意味着「每行都被执行」,不等于「边界都被覆盖」。
  • 追求 100% 会写出大量低价值测试,拖慢迭代。
  • 部分代码(如错误处理、防御性逻辑)覆盖率容易但价值低。

合理的目标是「关键路径 100%、整体 70-85%」。把精力放在业务核心逻辑上。

6. CI 中强制覆盖率

可以用工具(如 go-test-coverage)在 CI 中设置覆盖率阈值:

bash
go install github.com/vladopajic/go-test-coverage/v2@latest

# 配置文件 .testcoverage.yml
cat > .testcoverage.yml <<EOF
profile: cover.out
local-prefix: "github.com/yourorg/yourrepo"
threshold:
  file:
    min: 70
  package:
    min: 75
  total:
    min: 80
EOF

# 在 CI 中检查
go-test-coverage --config=.testcoverage.yml

如果覆盖率低于阈值,CI 失败。

六、TDD 流程

TDD(Test-Driven Development)是「测试驱动开发」,核心口诀是 Red-Green-Refactor

  1. Red:先写一个失败的测试,描述要实现的功能。
  2. Green:写最简单的实现让测试通过。
  3. Refactor:在不改变行为的前提下重构代码与测试。
text
┌─────────┐
│  Red    │  ← 写测试,运行失败
└────┬────┘

┌─────────┐
│ Green   │  ← 写实现,运行通过
└────┬────┘

┌─────────┐
│Refactor │  ← 重构,保持通过
└────┬────┘

   循环

示例:TDD 实现 FizzBuzz

Red:先写测试

go
package fizzbuzz_test

import (
	"testing"

	"github.com/stretchr/testify/assert"

	"example.com/fizzbuzz"
)

func TestFizzBuzz_Number1(t *testing.T) {
	assert.Equal(t, "1", fizzbuzz.FizzBuzz(1))
}

运行 go test

text
# example.com/fizzbuzz
./fizzbuzz.go:5:11: undefined: fizzbuzz.FizzBuzz
FAIL    example.com/fizzbuzz [build failed]

Green:写最小实现

go
// fizzbuzz.go
package fizzbuzz

func FizzBuzz(n int) string {
	return "1"
}

测试通过。但显然只对 1 有效。

Red:再加一个测试

go
func TestFizzBuzz_Number2(t *testing.T) {
	assert.Equal(t, "2", fizzbuzz.FizzBuzz(2))
}

测试失败(返回 "1")。

Green:修正实现

go
func FizzBuzz(n int) string {
	return strconv.Itoa(n)
}

测试通过。

Red:加 Fizz 规则

go
func TestFizzBuzz_Number3(t *testing.T) {
	assert.Equal(t, "Fizz", fizzbuzz.FizzBuzz(3))
}

Green

go
func FizzBuzz(n int) string {
	if n%3 == 0 {
		return "Fizz"
	}
	return strconv.Itoa(n)
}

Red → Green → Refactor:继续加 Buzz、FizzBuzz 规则,最后重构为更清晰的实现:

go
package fizzbuzz

import "strconv"

func FizzBuzz(n int) string {
	switch {
	case n%15 == 0:
		return "FizzBuzz"
	case n%3 == 0:
		return "Fizz"
	case n%5 == 0:
		return "Buzz"
	default:
		return strconv.Itoa(n)
	}
}

测试套件:

go
package fizzbuzz_test

import (
	"testing"

	"github.com/stretchr/testify/assert"

	"example.com/fizzbuzz"
)

func TestFizzBuzz(t *testing.T) {
	cases := []struct {
		input int
		want  string
	}{
		{1, "1"},
		{2, "2"},
		{3, "Fizz"},
		{4, "4"},
		{5, "Buzz"},
		{6, "Fizz"},
		{10, "Buzz"},
		{15, "FizzBuzz"},
		{30, "FizzBuzz"},
	}
	for _, c := range cases {
		t.Run(itoa(c.input), func(t *testing.T) {
			assert.Equal(t, c.want, fizzbuzz.FizzBuzz(c.input))
		})
	}
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	var digits []byte
	for n > 0 {
		digits = append([]byte{byte('0' + n%10)}, digits...)
		n /= 10
	}
	return string(digits)
}

TDD 的优缺点

优点:

  • 强制开发者思考「输入-输出」契约,提升设计。
  • 测试覆盖率天然较高(每个功能都有对应测试)。
  • 重构有保障,敢动代码。

缺点:

  • 写小函数时显得啰嗦。
  • 不适合「探索性」开发(不知道接口长啥样时)。
  • 容易写出「形式通过、本质无效」的测试。

何时用 TDD

  • 修复 bug 时:先写一个能复现 bug 的测试,再修复。
  • 实现算法/解析器:先写测试用例(含边界),再实现。
  • 重构关键代码:先补测试覆盖,再重构。
  • 不强制所有代码都 TDD:UI、原型、一次性脚本可以跳过。

七、BDD 与 Go

BDD(Behavior-Driven Development)是 TDD 的延伸,强调用「业务可读」的语言描述测试。最常见的 BDD 框架是 Cucumber,用 Gherkin 语法:

text
Feature: 用户登录
  Scenario: 用户用正确的用户名密码登录
    Given 一个已注册用户 "Alice"
    When 用户用用户名 "Alice" 和密码 "secret" 登录
    Then 应该返回 "登录成功"

Go 生态的 BDD 工具:

1. Ginkgo

github.com/onsi/ginkgo 是 Go 最流行的 BDD 框架,语法接近 RSpec:

go
package user_test

import (
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"

	"example.com/user"
)

var _ = Describe("User", func() {
	Describe("Validate", func() {
		Context("with valid fields", func() {
			It("should return no error", func() {
				u := user.User{Name: "Alice", Email: "alice@example.com", Age: 18}
				Expect(u.Validate()).To(Succeed())
			})
		})

		Context("with empty name", func() {
			It("should return ErrInvalidName", func() {
				u := user.User{Name: "", Email: "alice@example.com", Age: 18}
				err := u.Validate()
				Expect(err).To(MatchError(user.ErrInvalidName))
			})
		})

		Context("with invalid email", func() {
			It("should return ErrInvalidEmail", func() {
				u := user.User{Name: "Alice", Email: "not-an-email", Age: 18}
				err := u.Validate()
				Expect(err).To(MatchError(user.ErrInvalidEmail))
			})
		})
	})
})

func TestSuite(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "User Suite")
}

2. Gomega

github.com/onsi/gomega 是与 Ginkgo 配套的断言库,提供 Expect(x).To(Equal(y)) 风格的断言,比 testify 更「链式」:

go
Expect(1).To(Equal(1))
Expect("hello").To(ContainSubstring("ell"))
Expect([]int{1, 2, 3}).To(ContainElement(2))
Expect(err).NotTo(HaveOccurred())

3. GoBDD / Godog

github.com/cucumber/godog 是 Cucumber 的 Go 实现,支持 Gherkin 语法:

go
package main

import (
	"context"
	"fmt"

	"github.com/cucumber/godog"
)

type userFeature struct {
	user *User
	err  error
}

func (f *userFeature) aRegisteredUser(name string) error {
	f.user = &User{Name: name}
	return nil
}

func (f *userFeature) userValidates() error {
	f.err = f.user.Validate()
	return nil
}

func (f *userFeature) shouldReturnNoError() error {
	if f.err != nil {
		return fmt.Errorf("expected no error, got %v", f.err)
	}
	return nil
}

func InitializeScenario(ctx *godog.ScenarioContext) {
	f := &userFeature{}
	ctx.Step(`^a registered user "([^"]*)"$`, f.aRegisteredUser)
	ctx.Step(`^user validates$`, f.userValidates)
	ctx.Step(`^should return no error$`, f.shouldReturnNoError)
}

func main() {
	suite := godog.TestSuite{
		ScenarioInitializer: InitializeScenario,
		Options: &godog.Options{
			Format:   "pretty",
			Paths:    []string{"features"},
		},
	}
	suite.Run()
}

BDD 的适用性

BDD 适合:

  • 业务可读性要求高的场景(如与产品经理协作)。
  • 行为驱动测试(自然语言描述)。
  • 大型团队,多角色协作。

不适合:

  • 纯技术测试(断言数据结构、算法)。
  • 小型项目(增加复杂度)。
  • Go 团队不熟悉 BDD 风格(学习成本)。

推荐:先用 testify 写好单元测试,只有在「需要业务可读」时才引入 BDD。

八、CI/CD 中的测试策略

1. 分层 CI

把测试按速度分层,避免「跑所有测试 = 慢」:

yaml
# .github/workflows/test.yml
name: test
on: [push, pull_request]

jobs:
  # 第一层:快速单元测试(每次 push 都跑)
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.22"
      - name: Run unit tests
        run: go test -short -race -count=1 ./...

  # 第二层:覆盖率检查
  coverage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - name: Generate coverage
        run: go test -coverprofile=coverage.out -covermode=atomic ./...
      - name: Check coverage
        run: |
          COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
          echo "Total coverage: $COVERAGE%"
          if (( $(echo "$COVERAGE < 70" | bc -l) )); then
            echo "Coverage below 70%, failing"
            exit 1
          fi

  # 第三层:集成测试(需要 Docker)
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - uses: docker/setup-buildx-action@v3
      - name: Run integration tests
        run: go test -tags=integration -v -count=1 ./...

  # 第四层:基准测试( nightly )
  benchmark:
    runs-on: ubuntu-latest
    schedule:
      - cron: "0 0 * * *"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - name: Run benchmarks
        run: |
          go test -bench=. -benchmem -count=10 > bench.txt
          cat bench.txt
      - name: Compare with main
        run: |
          git fetch origin main
          git checkout origin/main
          go test -bench=. -benchmem -count=10 > bench-main.txt
          git checkout -
          go install golang.org/x/perf/cmd/benchstat@latest
          benchstat bench-main.txt bench.txt || true

  # 第五层:Fuzz 测试( nightly )
  fuzz:
    runs-on: ubuntu-latest
    schedule:
      - cron: "0 1 * * *"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - name: Run fuzz tests for 5 minutes
        run: |
          go test -fuzz=FuzzParse -fuzztime=5m ./...

2. 关键参数

CI 中的 go test 推荐参数:

bash
# 标准模式:所有测试 + race 检测 + 不缓存
go test -race -count=1 ./...

# 详细输出
go test -race -count=1 -v ./...

# 跑指定包
go test -race -count=1 ./internal/user/...

# 跳过集成测试
go test -short -race -count=1 ./...

# 失败立即停止
go test -race -count=1 -failfast ./...

# 限制超时(避免死锁卡住 CI)
go test -race -count=1 -timeout=300s ./...

关键参数说明:

  • -race:开启数据竞争检测,CI 必备。
  • -count=1:禁用缓存,确保每次都真正跑。
  • -timeout=300s:避免死锁卡住整个 CI。
  • -failfast:失败立即停止,节省 CI 时间(但要看团队偏好)。
  • -short:跳过耗时测试,加快 PR 验证。

3. 缓存策略

Go 默认会缓存测试结果,相同代码下重复 go test 不会重跑。CI 中:

  • 不要禁用缓存:除非代码变了,否则跑缓存是浪费。
  • -count=1 显式禁用:只在「确实需要重跑」时用,如调试偶发问题。

4. 测试产物归档

CI 应该归档测试产物,方便后续分析:

yaml
- name: Upload coverage
  uses: actions/upload-artifact@v4
  with:
    name: coverage
    path: coverage.out

- name: Upload benchmark results
  uses: actions/upload-artifact@v4
  with:
    name: benchmark
    path: bench.txt

5. 集成覆盖率服务

把覆盖率推送到 Codecov、Coveralls 等服务,能在 PR 中显示覆盖率变化:

yaml
- name: Upload coverage to Codecov
  uses: codecov/codecov-action@v4
  with:
    file: ./coverage.out
    token: ${{ secrets.CODECOV_TOKEN }}

九、测试最佳实践总结

1. 黄金法则

  1. 测试是文档:好的测试名能当需求文档读,坏的测试名让人摸不着头脑。
  2. 测行为,不测实现:测「输入产生什么输出」,不测「内部调用了哪些方法」。
  3. 每个测试独立:能单独跑、能并行跑、能任意顺序跑。
  4. 测试快:单元测试毫秒级,集成测试秒级。慢测试会被跳过。
  5. 失败信息有用:断言失败时,要能立刻知道「期望什么、得到什么」。

2. 测试金字塔实践

层级占比工具速度目标
单元测试70-80%testing + testify< 10ms/个
集成测试15-20%SQLite + testcontainers< 1s/个
E2E 测试5-10%httptest + 真实服务< 30s/个

3. 何时写测试

  • 新功能:开发同时写(或先写,TDD)。
  • 修 bug:先写复现 bug 的测试,再修。
  • 重构:先补测试覆盖,再动代码。
  • 加新分支:补一个测试覆盖新分支。

4. 何时不写测试

  • 一次性脚本(不值得)。
  • 探索性原型(接口还在变)。
  • UI 视觉(自动化测试收益低)。
  • 第三方库的简单封装(库自己测过)。

5. 测试代码也要维护

测试代码不是「写完就忘」。它和业务代码一样需要:

  • 命名清晰。
  • 提取公共 setup。
  • 删除过时的测试。
  • 定期 review。

6. 团队文化

  • PR 必须包含测试:新代码无测试不合并。
  • CI 必须通过:测试失败不能合并。
  • 覆盖率作为参考,不是门槛:用覆盖率找盲点,不要为了达标写无用测试。
  • 测试坏味道也要 review:不能因为「是测试代码」就降低标准。

十、完整示例:一个测试友好型小项目

最后用一个小项目把所有原则串起来。目录结构:

text
calculator/
├── go.mod
├── calc.go
├── calc_test.go
└── calc_internal_test.go

calc.go

go
// Package calculator 提供简单的计算器功能。
package calculator

import (
	"errors"
	"math"
	"strconv"
)

var (
	ErrDivisionByZero = errors.New("division by zero")
	ErrInvalidInput   = errors.New("invalid input")
)

// Calculator 是一个计算器,可记录操作历史。
type Calculator struct {
	history []string
}

func New() *Calculator {
	return &Calculator{}
}

func (c *Calculator) Add(a, b float64) float64 {
	result := a + b
	c.history = append(c.history, formatOp("add", a, b, result))
	return result
}

func (c *Calculator) Subtract(a, b float64) float64 {
	result := a - b
	c.history = append(c.history, formatOp("subtract", a, b, result))
	return result
}

func (c *Calculator) Multiply(a, b float64) float64 {
	result := a * b
	c.history = append(c.history, formatOp("multiply", a, b, result))
	return result
}

func (c *Calculator) Divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, ErrDivisionByZero
	}
	result := a / b
	c.history = append(c.history, formatOp("divide", a, b, result))
	return result, nil
}

// Sqrt 计算平方根。负数返回错误。
func (c *Calculator) Sqrt(x float64) (float64, error) {
	if x < 0 {
		return 0, ErrInvalidInput
	}
	if math.IsInf(x, 1) {
		return 0, ErrInvalidInput
	}
	result := math.Sqrt(x)
	c.history = append(c.history, formatOp("sqrt", x, 0, result))
	return result, nil
}

// History 返回操作历史的副本。
func (c *Calculator) History() []string {
	cp := make([]string, len(c.history))
	copy(cp, c.history)
	return cp
}

// Reset 清空历史。
func (c *Calculator) Reset() {
	c.history = c.history[:0]
}

// formatOp 是内部辅助函数,格式化操作记录。
func formatOp(op string, a, b, result float64) string {
	if b == 0 && op != "sqrt" {
		return op + "(" + ftoa(a) + ") = " + ftoa(result)
	}
	return op + "(" + ftoa(a) + ", " + ftoa(b) + ") = " + ftoa(result)
}

// ftoa 是内部辅助函数,简化浮点数格式化。
func ftoa(f float64) string {
	return strconv.FormatFloat(f, 'f', 2, 64)
}

calc_test.go(外部测试,覆盖公开 API):

go
package calculator_test

import (
	"math"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"

	"example.com/calculator"
)

type CalculatorSuite struct {
	suite.Suite
	calc *calculator.Calculator
}

func (s *CalculatorSuite) SetupTest() {
	s.calc = calculator.New()
}

func (s *CalculatorSuite) TestAdd() {
	cases := []struct {
		name string
		a, b float64
		want float64
	}{
		{"positive", 1, 2, 3},
		{"negative", -1, -2, -3},
		{"mixed", -1, 1, 0},
		{"float", 1.5, 2.5, 4},
		{"zero", 0, 0, 0},
	}

	for _, c := range cases {
		s.Run(c.name, func() {
			got := s.calc.Add(c.a, c.b)
			s.Equal(c.want, got)
		})
	}
}

func (s *CalculatorSuite) TestSubtract() {
	s.Equal(float64(3), s.calc.Subtract(5, 2))
	s.Equal(float64(-3), s.calc.Subtract(2, 5))
}

func (s *CalculatorSuite) TestMultiply() {
	s.Equal(float64(6), s.calc.Multiply(2, 3))
	s.Equal(float64(0), s.calc.Multiply(0, 100))
	s.Equal(float64(-6), s.calc.Multiply(-2, 3))
}

func (s *CalculatorSuite) TestDivide() {
	t := s.T()

	t.Run("normal", func(t *testing.T) {
		result, err := s.calc.Divide(10, 2)
		require.NoError(t, err)
		assert.Equal(t, float64(5), result)
	})

	t.Run("by zero", func(t *testing.T) {
		_, err := s.calc.Divide(10, 0)
		require.Error(t, err)
		assert.ErrorIs(t, err, calculator.ErrDivisionByZero)
	})

	t.Run("non-integer result", func(t *testing.T) {
		result, err := s.calc.Divide(1, 3)
		require.NoError(t, err)
		assert.InDelta(t, 0.333333, result, 0.0001)
	})
}

func (s *CalculatorSuite) TestSqrt() {
	t := s.T()

	t.Run("positive", func(t *testing.T) {
		result, err := s.calc.Sqrt(4)
		require.NoError(t, err)
		assert.Equal(t, float64(2), result)
	})

	t.Run("zero", func(t *testing.T) {
		result, err := s.calc.Sqrt(0)
		require.NoError(t, err)
		assert.Equal(t, float64(0), result)
	})

	t.Run("negative", func(t *testing.T) {
		_, err := s.calc.Sqrt(-1)
		require.Error(t, err)
		assert.ErrorIs(t, err, calculator.ErrInvalidInput)
	})

	t.Run("infinity", func(t *testing.T) {
		_, err := s.calc.Sqrt(math.Inf(1))
		require.Error(t, err)
		assert.ErrorIs(t, err, calculator.ErrInvalidInput)
	})
}

func (s *CalculatorSuite) TestHistory() {
	s.calc.Add(1, 2)
	s.calc.Multiply(3, 4)

	history := s.calc.History()
	s.Len(history, 2)
	s.Contains(history[0], "add")
	s.Contains(history[1], "multiply")
}

func (s *CalculatorSuite) TestHistoryIsImmutable() {
	s.calc.Add(1, 2)
	h1 := s.calc.History()
	h1[0] = "modified"

	h2 := s.calc.History()
	s.NotEqual(h2[0], "modified", "返回的副本不应被外部修改影响")
}

func (s *CalculatorSuite) TestReset() {
	s.calc.Add(1, 2)
	s.calc.Reset()
	s.Empty(s.calc.History())
}

func TestCalculatorSuite(t *testing.T) {
	suite.Run(t, new(CalculatorSuite))
}

calc_internal_test.go(内部测试,覆盖未导出细节):

go
package calculator

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestFormatOp(t *testing.T) {
	cases := []struct {
		name   string
		op     string
		a, b   float64
		result float64
		want   string
	}{
		{"binary op", "add", 1, 2, 3, "add(1.00, 2.00) = 3.00"},
		{"unary op", "sqrt", 4, 0, 2, "sqrt(4.00) = 2.00"},
		{"negative", "subtract", -1, 1, -2, "subtract(-1.00, 1.00) = -2.00"},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := formatOp(c.op, c.a, c.b, c.result)
			assert.Equal(t, c.want, got)
		})
	}
}

func TestFtoa(t *testing.T) {
	assert.Equal(t, "0.00", ftoa(0))
	assert.Equal(t, "1.50", ftoa(1.5))
	assert.Equal(t, "-3.14", ftoa(-3.14))
}

执行:

bash
go test -v -race -cover -coverprofile=coverage.out

go tool cover -func=coverage.out

预期:

text
PASS
coverage: 95.0% of statements
ok      example.com/calculator  0.008s

十一、小结

本篇是 Go 测试系列的收尾篇,核心要点:

  1. 测试文件组织:与源文件同目录、_test.go 后缀;testdata/ 用于测试输入文件。
  2. 内部 vs 外部测试:优先 package xxx_test 测公开契约,必要时用 package xxx 测内部细节。
  3. 命名规范Test{Subject}_{Condition}_{ExpectedResult},子测试用 t.Run("name", ...)
  4. 测试坏味道:过多断言、依赖顺序、不可重复、重复实现、过度断言、不清理副作用。
  5. 覆盖率-coverprofile + go tool cover,目标按项目类型设定(库 85-95%、Web 60-80%),不强制 100%。
  6. TDD:Red-Green-Refactor 循环,适合 bug 修复、算法实现、重构。
  7. BDD:Ginkgo、Godog,强调业务可读,仅在需要产品经理协作时引入。
  8. CI/CD 策略:分层 CI(单元快、集成中、E2E 慢)、-race -count=1 -timeout 必备、覆盖率检查、产物归档、nightly Fuzz/Benchmark。
  9. 黄金法则:测试是文档、测行为不测实现、每个测试独立、测试快、失败信息有用。
  10. 测试也是代码:需要命名、重构、review、维护,不能因为是测试就降低标准。

十二、Go 测试系列总结

至此,8 篇 Go 测试教程告一段落。回顾整个系列:

  1. 测试基础testing 包、表驱动测试、子测试、并行测试。
  2. 断言与 testify:assert/require、suite、自定义断言。
  3. Mock 与接口测试:依赖注入、gomock、mockery、testify/mock、sqlmock。
  4. HTTP 测试:httptest、Gin 测试、中间件、客户端、E2E。
  5. 基准测试:b.N、内存分配、并行基准、benchstat 对比。
  6. Fuzz Testing:原生 Fuzz、种子语料、不变式、失败回归。
  7. 集成测试与测试容器:SQLite、testcontainers-go、TestMain、事务回滚、工厂模式。
  8. 测试最佳实践与覆盖率:组织、命名、坏味道、覆盖率、TDD/BDD、CI/CD。

这套技能覆盖了 Go 项目测试的方方面面,从一行 t.Errorf 到企业级 CI 流水线。把它们用起来,你的代码会更快、更稳、更易维护——这才是测试的最终价值。


测试不是负担,而是开发者的护身符。一份好的测试套件能让你:

  • 敢重构:因为有测试兜底。
  • 能迭代:因为每次提交都能验证。
  • 少加班:因为 bug 在本地就被发现,不会到生产才暴露。

把这套测试体系建起来,从一个小项目开始,逐步推广到整个团队。测试代码的复利效应会随时间显现——半年后回头看,你会感谢今天写下第一行 func TestXxx(t *testing.T) 的自己。