Go 补强第三天:给 TaskStore 写单元测试

背景

前两天已经完成了两个阶段:

  • 第一天:用 structslicemaperror 串起基础数据操作。
  • 第二天:把散函数封装成 TaskStore,形成一个小模块。

第三天的目标是继续往真实项目靠近:把代码放进独立 package,并用 go test 验证它。

这一步的意义是:代码不只是"能跑",还要"能被验证"。

1. 从 main 程序拆成 package

这次创建了一个独立目录:

text 复制代码
demo/taskstore/

并初始化模块:

powershell 复制代码
go mod init taskstore

一开始遇到了一个问题:

text 复制代码
go: error reading go.mod: missing module declaration

原因是 go.mod 文件存在,但内容是空的,没有模块声明。

修复方式:

powershell 复制代码
go mod edit -module=taskstore

修复后 go.mod 至少应该包含:

go 复制代码
module taskstore

然后运行:

powershell 复制代码
go test

输出:

text 复制代码
?       taskstore       [no test files]

这个输出说明:Go 已经识别当前目录是一个 package,只是还没有测试文件。

2. 第一个测试:Create 成功

测试文件命名为:

text 复制代码
store_test.go

测试函数必须符合:

go 复制代码
func TestXxx(t *testing.T)

例如:

go 复制代码
func TestStoreCreate(t *testing.T) {
    store := NewStore()

    task, err := store.Create("学习测试", 3)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    if task.ID != 1 {
        t.Fatalf("expected ID 1, got %d", task.ID)
    }

    if task.Title != "学习测试" {
        t.Fatalf("expected title 学习测试, got %s", task.Title)
    }

    if task.Done {
        t.Fatalf("expected Done false, got true")
    }

    if task.Priority != 3 {
        t.Fatalf("expected priority 3, got %d", task.Priority)
    }
}

这里第一次理解了测试的基本写法:

text 复制代码
准备数据
执行操作
检查结果

也就是常说的 Arrange / Act / Assert。

3. 不只测成功,也要测失败

Create 不应该只测正常标题,还要测空标题。

go 复制代码
func TestStoreCreateEmptyTitle(t *testing.T) {
    store := NewStore()

    _, err := store.Create("   ", 3)
    if err == nil {
        t.Fatalf("expected error, got nil")
    }

    if err != ErrEmptyTitle {
        t.Fatalf("expected ErrEmptyTitle, got %v", err)
    }
}

这一步的重点是:

text 复制代码
单元测试要覆盖成功路径,也要覆盖错误路径。

否则代码看起来能用,但边界条件可能没有被验证。

4. 测试 Get

Get 的成功路径需要先准备数据:

go 复制代码
func TestStoreGet(t *testing.T) {
    store := NewStore()

    created, err := store.Create("学习 Get", 2)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    got, err := store.Get(created.ID)
    if err != nil {
        t.Fatalf("Get returned error: %v", err)
    }

    if got.ID != created.ID {
        t.Fatalf("expected ID %d, got %d", created.ID, got.ID)
    }

    if got.Title != created.Title {
        t.Fatalf("expected title %s, got %s", created.Title, got.Title)
    }

    if got.Priority != created.Priority {
        t.Fatalf("expected priority %d, got %d", created.Priority, got.Priority)
    }
}

不存在的情况也要测:

go 复制代码
func TestStoreGetNotFound(t *testing.T) {
    store := NewStore()

    _, err := store.Get(99)
    if err == nil {
        t.Fatalf("expected error, got nil")
    }

    if err != ErrTaskNotFound {
        t.Fatalf("expected ErrTaskNotFound, got %v", err)
    }
}

5. 测试 MarkDone

修改类方法不能只看返回值,还要确认状态真的变了。

go 复制代码
func TestStoreMarkDone(t *testing.T) {
    store := NewStore()

    created, err := store.Create("学习 MarkDone", 1)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    err = store.MarkDone(created.ID)
    if err != nil {
        t.Fatalf("MarkDone returned error: %v", err)
    }

    got, err := store.Get(created.ID)
    if err != nil {
        t.Fatalf("Get returned error: %v", err)
    }

    if !got.Done {
        t.Fatalf("expected Done true, got false")
    }
}

不存在的任务也要测:

go 复制代码
func TestStoreMarkDoneNotFound(t *testing.T) {
    store := NewStore()

    err := store.MarkDone(99)
    if err == nil {
        t.Fatalf("expected error, got nil")
    }

    if err != ErrTaskNotFound {
        t.Fatalf("expected ErrTaskNotFound, got %v", err)
    }
}

6. 测试 Delete

删除操作的测试重点是:删除后再查一次。

go 复制代码
func TestStoreDelete(t *testing.T) {
    store := NewStore()

    created, err := store.Create("学习 Delete", 1)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    err = store.Delete(created.ID)
    if err != nil {
        t.Fatalf("Delete returned error: %v", err)
    }

    _, err = store.Get(created.ID)
    if err == nil {
        t.Fatalf("expected error after delete, got nil")
    }

    if err != ErrTaskNotFound {
        t.Fatalf("expected ErrTaskNotFound, got %v", err)
    }
}

删除不存在任务:

go 复制代码
func TestStoreDeleteNotFound(t *testing.T) {
    store := NewStore()

    err := store.Delete(99)
    if err == nil {
        t.Fatalf("expected error, got nil")
    }

    if err != ErrTaskNotFound {
        t.Fatalf("expected ErrTaskNotFound, got %v", err)
    }
}

这里的理解是:

text 复制代码
Delete 返回 nil 只能说明方法没有报错。
再 Get 一次,才能确认数据真的被删除了。

7. 测试 List

List 测试先只验证数量:

go 复制代码
func TestStoreList(t *testing.T) {
    store := NewStore()

    _, err := store.Create("任务 1", 1)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    _, err = store.Create("任务 2", 2)
    if err != nil {
        t.Fatalf("Create returned error: %v", err)
    }

    tasks := store.List()

    if len(tasks) != 2 {
        t.Fatalf("expected 2 tasks, got %d", len(tasks))
    }
}

这里刻意没有测试:

go 复制代码
tasks[0].Title == "任务 1"

因为 Store 内部使用 map 保存任务,而 Go 不保证 map 的遍历顺序。

所以 List() 返回的顺序不能依赖。如果以后业务需要稳定顺序,就应该在 List 里显式排序。

8. 今天的测试覆盖

今天最终覆盖了这些场景:

text 复制代码
Create 成功
Create 空标题失败
Get 成功
Get 不存在失败
MarkDone 成功
MarkDone 不存在失败
Delete 成功
Delete 不存在失败
List 返回数量

运行:

powershell 复制代码
go test

输出:

text 复制代码
PASS
ok      taskstore

9. 今天的几个关键理解

TestXxx 是 Go 的测试识别规则

Go 会自动识别形如下面的函数:

go 复制代码
func TestStoreCreate(t *testing.T)

函数名必须以 Test 开头,参数必须是 *testing.T

t.Fatalf 会让测试失败并停止当前测试

fmt.Println 只是打印内容,不会告诉测试框架失败。

而:

go 复制代码
t.Fatalf("expected ID 1, got %d", task.ID)

会标记测试失败,并停止当前测试函数。

测试要验证行为结果

比如 Delete,不能只看 Delete 有没有返回错误,还要再 Get 一次确认数据确实不存在。

不要依赖 map 遍历顺序

因为 Go 的 map 遍历顺序不固定,所以 List 测试只验证数量,不验证顺序。

今天的收获

第三天最大的收获是:开始从"写代码能跑"转向"写代码能被验证"。

现在这个小模块已经不只是有功能,还能通过测试保护基本行为。

下一步

下一篇可以继续做:

text 复制代码
把测试改成表驱动测试,并学习 errors.Is 的错误判断方式。

这样可以减少重复测试代码,也更接近真实 Go 项目的测试写法。

相关推荐
企业数字化笔记1 小时前
固定资产历史数据怎么导入系统?Excel模板、字段映射和数据校验
android·java·数据库·后端
西瓜太郎12341 小时前
request_id 如何串起请求、路由、计费和日志
后端·python
by组态1 小时前
Ricon组态系统通信配置指南
前端·后端·物联网
sbjdhjd1 小时前
ThinkPHP 5.0.10 缓存写入型 RCE 复盘:从手动搭建、换行绕过到源码单步验证 | 05
java·后端·安全·spring·网络安全·数据挖掘·php
LEE1 小时前
Agent 的控制权,为什么正在回到模型手里?
前端·后端
沃夫上校1 小时前
跨域这件事,其实浏览器在替你挡刀
前端·后端·nginx
用户298698530141 小时前
Python 将 Word 文档转换为图片的实践指南
后端·python·api
旺仔不是程序员1 小时前
致命索引失效:索引列运算与函数索引不匹配
数据库·后端·面试
老孙讲技术1 小时前
把连锁门店轮询抓拍和遮挡叫醒接进督导台-setDeviceSnapEnhanced与setMessageCallback
后端·物联网·音视频开发
EatFan2 小时前
前后端分离项目中 Token 到底应该怎么设计?Access Token + Refresh Token 实战
java·前端·后端