Go 补强第四天:把重复单测改成表驱动测试

背景

第三天已经给 TaskStore 写了基础单元测试,覆盖了:

  • Create
  • Get
  • MarkDone
  • Delete
  • List

但这些测试有一个问题:重复代码比较多。

比如:

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:测试前准备初始数据

测试 GetMarkDoneDelete 时,经常需要先让 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 什么时候按值传,什么时候用指针
  • slicemapstruct 传参到底有什么区别
相关推荐
范闲1 小时前
第二章:Octo v0.1.1:从「能用」到「好用」——终端 AI 聊天体验的全面升级
人工智能·后端
贰先生1 小时前
Xiuno BBS 审计之问题03:附件上传高危风险
后端
Ai拆代码的曹操1 小时前
记一次 K8s Pod Pending 排查:两层坑叠加,从资源到存储的完整复盘
后端
贰先生1 小时前
Xiuno BBS 审计之问题04:Cookie 缺少 HttpOnly/Secure/SameSite 安全属性
后端
贰先生1 小时前
Xiuno BBS 审计之问题06:硬编码 auth_key 加密密钥
后端
贰先生1 小时前
Xiuno BBS 审计之问题02:后台用户搜索可能造成严重性能 DoS
后端
范闲1 小时前
第一章:从零构建终端 AI 助手 —— Octo 项目实战
后端·llm
Gopher_HBo2 小时前
docker-run-调用链全图解
后端
贰先生2 小时前
Xiuno BBS 审计之问题09:登录回跳 referer 未严格校验,存在开放重定向风险
后端