Appearance
测试最佳实践与覆盖率
经过前 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这种组织的优点:
- 物理邻近:测试与被测代码在同一个目录,
go test ./...自动发现。 - 可读性:开发者看
user.go时,旁边就是user_test.go,验证逻辑随手可查。 - 包内访问:测试文件可以与源文件同包(
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 是期望结果。
- 避免模糊名称如
Test1、TestHandle、TestSomething。
子测试
子测试用 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.Cleanup 或 defer:
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.outHTML 报告中:
- 绿色:被覆盖的行。
- 红色:未覆盖的行。
- 黄色:部分覆盖(如 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. 覆盖率目标设定
覆盖率应该设多高?没有标准答案,但有一些经验值:
| 项目类型 | 推荐目标 | 理由 |
|---|---|---|
| 库 / SDK | 85-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:
- Red:先写一个失败的测试,描述要实现的功能。
- Green:写最简单的实现让测试通过。
- 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.txt5. 集成覆盖率服务
把覆盖率推送到 Codecov、Coveralls 等服务,能在 PR 中显示覆盖率变化:
yaml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
token: ${{ secrets.CODECOV_TOKEN }}九、测试最佳实践总结
1. 黄金法则
- 测试是文档:好的测试名能当需求文档读,坏的测试名让人摸不着头脑。
- 测行为,不测实现:测「输入产生什么输出」,不测「内部调用了哪些方法」。
- 每个测试独立:能单独跑、能并行跑、能任意顺序跑。
- 测试快:单元测试毫秒级,集成测试秒级。慢测试会被跳过。
- 失败信息有用:断言失败时,要能立刻知道「期望什么、得到什么」。
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.gocalc.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 测试系列的收尾篇,核心要点:
- 测试文件组织:与源文件同目录、
_test.go后缀;testdata/用于测试输入文件。 - 内部 vs 外部测试:优先
package xxx_test测公开契约,必要时用package xxx测内部细节。 - 命名规范:
Test{Subject}_{Condition}_{ExpectedResult},子测试用t.Run("name", ...)。 - 测试坏味道:过多断言、依赖顺序、不可重复、重复实现、过度断言、不清理副作用。
- 覆盖率:
-coverprofile+go tool cover,目标按项目类型设定(库 85-95%、Web 60-80%),不强制 100%。 - TDD:Red-Green-Refactor 循环,适合 bug 修复、算法实现、重构。
- BDD:Ginkgo、Godog,强调业务可读,仅在需要产品经理协作时引入。
- CI/CD 策略:分层 CI(单元快、集成中、E2E 慢)、
-race -count=1 -timeout必备、覆盖率检查、产物归档、nightly Fuzz/Benchmark。 - 黄金法则:测试是文档、测行为不测实现、每个测试独立、测试快、失败信息有用。
- 测试也是代码:需要命名、重构、review、维护,不能因为是测试就降低标准。
十二、Go 测试系列总结
至此,8 篇 Go 测试教程告一段落。回顾整个系列:
- 测试基础:
testing包、表驱动测试、子测试、并行测试。 - 断言与 testify:assert/require、suite、自定义断言。
- Mock 与接口测试:依赖注入、gomock、mockery、testify/mock、sqlmock。
- HTTP 测试:httptest、Gin 测试、中间件、客户端、E2E。
- 基准测试:b.N、内存分配、并行基准、benchstat 对比。
- Fuzz Testing:原生 Fuzz、种子语料、不变式、失败回归。
- 集成测试与测试容器:SQLite、testcontainers-go、TestMain、事务回滚、工厂模式。
- 测试最佳实践与覆盖率:组织、命名、坏味道、覆盖率、TDD/BDD、CI/CD。
这套技能覆盖了 Go 项目测试的方方面面,从一行 t.Errorf 到企业级 CI 流水线。把它们用起来,你的代码会更快、更稳、更易维护——这才是测试的最终价值。
测试不是负担,而是开发者的护身符。一份好的测试套件能让你:
- 敢重构:因为有测试兜底。
- 能迭代:因为每次提交都能验证。
- 少加班:因为 bug 在本地就被发现,不会到生产才暴露。
把这套测试体系建起来,从一个小项目开始,逐步推广到整个团队。测试代码的复利效应会随时间显现——半年后回头看,你会感谢今天写下第一行 func TestXxx(t *testing.T) 的自己。