背景
第三天已经给 TaskStore 写了基础单元测试,覆盖了:
CreateGetMarkDoneDeleteList
但这些测试有一个问题:重复代码比较多。
比如:
text
TestStoreGet
TestStoreGetNotFound
其实都是在测试 Get 这个行为,只是输入和预期结果不同。
第四天的目标就是把这种重复测试改成 Go 项目里很常见的写法:表驱动测试。
1. 什么是表驱动测试
表驱动测试的核心思想是:
text
把多组输入和预期结果放进一张用例表里,然后用同一段测试逻辑逐条执行。
典型结构是:
go
tests := []struct {
name string
input string
wantErr error
}{
{
name: "success",
input: "hello",
wantErr: nil,
},
{
name: "empty",
input: "",
wantErr: ErrEmptyTitle,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 执行测试
})
}
我对它的理解是:
text
tests 是用例表。
tt 是当前这一条测试用例。
t.Run 用来把每条用例变成一个子测试。
2. 改造 Create 测试
原来有两个测试:
go
TestStoreCreate
TestStoreCreateEmptyTitle
改造成一个表驱动测试:
go
func TestStoreCreate(t *testing.T) {
tests := []struct {
name string
title string
priority int
wantErr error
wantTitle string
wantID int64
wantDone bool
wantPriority int
}{
{
name: "success",
title: "学习测试",
priority: 3,
wantErr: nil,
wantTitle: "学习测试",
wantID: 1,
wantDone: false,
wantPriority: 3,
},
{
name: "empty title",
title: " ",
priority: 3,
wantErr: ErrEmptyTitle,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
task, err := store.Create(tt.title, tt.priority)
if err != tt.wantErr {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
if tt.wantErr != nil {
return
}
if task.ID != tt.wantID {
t.Fatalf("expected ID %d, got %d", tt.wantID, task.ID)
}
if task.Title != tt.wantTitle {
t.Fatalf("expected title %s, got %s", tt.wantTitle, task.Title)
}
if task.Done != tt.wantDone {
t.Fatalf("expected Done %v, got %v", tt.wantDone, task.Done)
}
if task.Priority != tt.wantPriority {
t.Fatalf("expected priority %d, got %d", tt.wantPriority, task.Priority)
}
})
}
}
这样成功场景和失败场景都放进了同一个测试函数。
3. t.Run 的作用
t.Run 的作用是给每条测试用例一个名字:
go
t.Run(tt.name, func(t *testing.T) {
// 当前用例的测试逻辑
})
这样测试失败时,可以看到是哪一条用例失败。
比如:
text
TestStoreCreate/empty_title
这比只看到 TestStoreCreate 失败更容易定位问题。
4. 为什么每个用例都重新 NewStore
每个子测试里都写:
go
store := NewStore()
原因是:每条用例应该尽量独立。
如果多个用例共用同一个 store,前一个用例创建、修改或删除的数据,可能影响后一个用例。
所以这里的原则是:
text
每条测试用例准备自己的初始状态。
这样测试更稳定,也更容易定位问题。
5. seedTasks:测试前准备初始数据
测试 Get、MarkDone、Delete 时,经常需要先让 Store 里有一些数据。
所以用 seedTasks 表示初始数据:
go
tests := []struct {
name string
seedTasks []Task
id int64
wantErr error
}{
{
name: "success",
seedTasks: []Task{
{ID: 1, Title: "学习 Get", Done: false, Priority: 2},
},
id: 1,
wantErr: nil,
},
{
name: "not found",
seedTasks: nil,
id: 99,
wantErr: ErrTaskNotFound,
},
}
执行前把数据放进 store:
go
for _, task := range tt.seedTasks {
store.tasks[task.ID] = task
if task.ID >= store.nextID {
store.nextID = task.ID + 1
}
}
这里直接访问了:
go
store.tasks
store.nextID
因为测试文件和源码都在同一个包里:
go
package taskstore
所以测试可以访问未导出的字段。
这不是业务代码推荐做法,而是测试里为了精确准备状态。
6. 改造 Get 测试
Get 的成功和失败路径合并成:
go
func TestStoreGet(t *testing.T) {
tests := []struct {
name string
seedTasks []Task
id int64
wantErr error
wantTitle string
}{
{
name: "success",
seedTasks: []Task{
{ID: 1, Title: "学习 Get", Done: false, Priority: 2},
},
id: 1,
wantErr: nil,
wantTitle: "学习 Get",
},
{
name: "not found",
seedTasks: nil,
id: 99,
wantErr: ErrTaskNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
for _, task := range tt.seedTasks {
store.tasks[task.ID] = task
if task.ID >= store.nextID {
store.nextID = task.ID + 1
}
}
got, err := store.Get(tt.id)
if err != tt.wantErr {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
if tt.wantErr != nil {
return
}
if got.Title != tt.wantTitle {
t.Fatalf("expected title %s, got %s", tt.wantTitle, got.Title)
}
})
}
}
这里的关键是:
text
先用 seedTasks 准备 Store 状态,再执行 Get,再断言结果。
7. 改造 MarkDone 和 Delete
MarkDone 的测试重点是:修改后再查一次,确认状态真的变了。
go
got, err := store.Get(tt.id)
if err != nil {
t.Fatalf("Get returned error: %v", err)
}
if got.Done != tt.wantDone {
t.Fatalf("expected Done %v, got %v", tt.wantDone, got.Done)
}
Delete 的测试重点是:删除后再查一次,确认任务真的不存在。
go
_, err = store.Get(tt.id)
gotGone := err == ErrTaskNotFound
if gotGone != tt.wantGone {
t.Fatalf("expected gone %v, got %v", tt.wantGone, gotGone)
}
这两个测试提醒我:测试不能只看方法有没有返回错误,还要验证业务状态是否真的变化。
8. List 测试仍然不依赖顺序
List 的表驱动测试:
go
func TestStoreList(t *testing.T) {
tests := []struct {
name string
seedTasks []Task
wantLen int
}{
{
name: "empty",
seedTasks: nil,
wantLen: 0,
},
{
name: "two tasks",
seedTasks: []Task{
{ID: 1, Title: "任务 1", Done: false, Priority: 1},
{ID: 2, Title: "任务 2", Done: false, Priority: 2},
},
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
for _, task := range tt.seedTasks {
store.tasks[task.ID] = task
if task.ID >= store.nextID {
store.nextID = task.ID + 1
}
}
got := store.List()
if len(got) != tt.wantLen {
t.Fatalf("expected len %d, got %d", tt.wantLen, len(got))
}
})
}
}
这里还是只测长度,不测第一个任务是谁。
原因是:
text
Store 内部使用 map,Go 不保证 map 遍历顺序。
9. 今天的收获
第四天最大的收获是:测试代码也需要组织。
表驱动测试不是为了炫技,而是为了解决几个实际问题:
- 减少重复测试代码
- 让成功和失败路径放在同一个行为测试里
- 每条用例都有名字,失败时更好定位
- 更方便继续追加边界场景
今天之后,测试结构从:
text
TestStoreGet
TestStoreGetNotFound
TestStoreDelete
TestStoreDeleteNotFound
变成了:
text
TestStoreGet
success
not found
TestStoreDelete
success
not found
这更接近真实 Go 项目里的测试写法。
仍然需要继续巩固的点
tests := []struct {...}{...}的语法还需要多写几次。t.Run的子测试思想需要继续熟悉。- 什么时候直接用
Create准备数据,什么时候用seedTasks直接准备内部状态。 - 当前错误比较用的是
err != tt.wantErr,下一步可以学习errors.Is。
下一步
下一篇准备学习:
text
指针、值拷贝和方法接收者
重点解决:
- 为什么
Store方法用*Store Task什么时候按值传,什么时候用指针slice、map、struct传参到底有什么区别