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

3 阅读4分钟

背景

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

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

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

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

1. 从 main 程序拆成 package

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

demo/taskstore/

并初始化模块:

go mod init taskstore

一开始遇到了一个问题:

go: error reading go.mod: missing module declaration

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

修复方式:

go mod edit -module=taskstore

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

module taskstore

然后运行:

go test

输出:

?       taskstore       [no test files]

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

2. 第一个测试:Create 成功

测试文件命名为:

store_test.go

测试函数必须符合:

func TestXxx(t *testing.T)

例如:

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)
    }
}

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

准备数据
执行操作
检查结果

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

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

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

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)
    }
}

这一步的重点是:

单元测试要覆盖成功路径,也要覆盖错误路径。

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

4. 测试 Get

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

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)
    }
}

不存在的情况也要测:

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

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

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")
    }
}

不存在的任务也要测:

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

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

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)
    }
}

删除不存在任务:

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)
    }
}

这里的理解是:

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

7. 测试 List

List 测试先只验证数量:

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))
    }
}

这里刻意没有测试:

tasks[0].Title == "任务 1"

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

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

8. 今天的测试覆盖

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

Create 成功
Create 空标题失败
Get 成功
Get 不存在失败
MarkDone 成功
MarkDone 不存在失败
Delete 成功
Delete 不存在失败
List 返回数量

运行:

go test

输出:

PASS
ok      taskstore

9. 今天的几个关键理解

TestXxx 是 Go 的测试识别规则

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

func TestStoreCreate(t *testing.T)

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

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

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

而:

t.Fatalf("expected ID 1, got %d", task.ID)

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

测试要验证行为结果

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

不要依赖 map 遍历顺序

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

今天的收获

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

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

下一步

下一篇可以继续做:

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

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