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 项目的测试写法。

相关推荐
卷无止境2 分钟前
Linux + Docker + FastAPI 工程化实践指南
后端·python·fastapi
AI绘画哇哒哒9 小时前
【建议收藏!】35岁后端血泪忠告,这3类人别硬转Agent(过来人亲述)
java·人工智能·后端·ai·程序员·大模型·agent
唐青枫10 小时前
一个值多种形态:Zig union、Tagged Union 与内存布局实战
后端
卷无止境10 小时前
Windows 上丝滑开发 Python,并稳定构建 Docker 镜像
后端·python·docker
Nturmoils10 小时前
备份完不算完,先还原到临时库验一遍
后端
Csvn11 小时前
📊 SQL 入门 Day 22:视图与物化视图
后端·sql
Csvn11 小时前
🐍 Day 4: Python 控制流 — 条件、循环与推导式的艺术
后端·python
2601_9537208211 小时前
【计算机毕业设计】基于Vue与Spring Boot的高校兼职信息服务平台设计与实现
spring boot·后端·课程设计
再吃一根胡萝卜13 小时前
用 Rust 写一个桌面悬浮图标:为什么它比 Python 更适合 AI 桌面工具?
后端
IT_陈寒13 小时前
Vue的computed属性把我坑惨了,原来我一直用错姿势
前端·人工智能·后端