Go:服务怎么做表驱动测试、mock 和集成测试分层

学 Go 学到服务开发阶段时,会很快遇到一类很典型的困惑:

  • 单元测试会写一点,但一到真实服务就不知道从哪下手
  • 听过表驱动测试,也照着写过 cases := []struct{...},但总觉得只是形式
  • 看到别人说要 mock,又不知道 Go 里到底该不该大量 mock
  • 集成测试一跑就慢,一慢就不想跑
  • 偶尔还有一两条测试本地能过,CI 上却随机失败

这类问题通常不是因为测试框架不会,而是因为测试分层和依赖边界没有建立起来

Go 服务测试真正要先看清的是:

  • 哪些逻辑可以纯函数化,用表驱动测试快速兜住
  • 哪些逻辑需要依赖替身,验证业务分支和错误路径
  • 哪些地方必须接真实适配器,才能证明工程链路真的成立
  • 哪些测试不稳定,其实不是“运气不好”,而是代码本身有竞争、时间、环境或资源边界问题

这一篇就围绕一个实际小场景来讲:做一个最小的服务巡检任务服务

这个服务要做几件事:

  1. 接收一批待巡检目标
  2. 校验环境、目标数量和超时参数
  3. 调用下游健康检查接口
  4. 汇总巡检结果
  5. 把结果落盘保存
  6. 通过 HTTP 接口对外提供执行入口

这个场景不大,但足够把 Go 服务测试里最常见的几层一次串起来。

一、这篇文章要解决什么问题

读完这一篇,应该能独立回答这些问题:

  1. 表驱动测试到底适合解决什么问题
  2. Go 里的 mock、fake、stub、spy 分别是什么关系
  3. 服务层测试为什么不该全部依赖真实下游
  4. 为什么也不能把所有测试都做成 mock 测试
  5. HTTP handler、service、repository、下游 client 应该分别怎么测
  6. 集成测试到底在证明什么
  7. flaky test 通常从哪几类问题开始排查

如果这些问题能答清,后面你写 Go HTTP 服务、任务执行器、调度器、测试平台组件时,测试结构会稳很多。

二、先把一句话原则说清楚

Go 服务测试最稳的做法,不是押注某一种测试方式,而是按变化速度、依赖成本和故障半径分层:

  1. 纯规则层
    优先写表驱动测试,跑得快、反馈准

  2. 服务编排层
    优先用 fake 或最小 mock,验证业务决策、错误传播和调用边界

  3. 适配器层
    httptest、临时目录、测试数据库、测试容器去做集成测试,验证协议、序列化、I/O、配置和真实交互

  4. 端到端层
    数量最少,只验证关键主链路,不负责覆盖所有分支

如果一上来就把所有逻辑都扔进集成测试,反馈会很慢。
如果反过来把所有东西都 mock 掉,又会得到一堆“假绿灯”。

三、先看这篇文章里的小项目

假设现在要做一个最小巡检任务服务,暴露一个接口:

POST /api/probe/run

请求体:

1
2
3
4
5
{
"env": "prod",
"targets": ["order-api", "user-api"],
"timeout_sec": 2
}

服务要做的动作是:

  1. 校验 env
  2. 校验目标列表不能为空,且不能超过上限
  3. context.WithTimeout 控制整批巡检超时
  4. 调用下游健康检查器获取每个目标状态
  5. 生成报告
  6. 把报告通过 repository 落盘
  7. 把结果 JSON 返回给调用方

为了让测试结构清晰,先把代码边界拆成四层:

  • ValidateRequest:纯规则校验
  • ProbeService:业务编排
  • HTTPChecker:真正请求下游健康检查接口
  • FileReportRepository:真正把报告写入文件

这个拆法不是为了“看起来像架构图”,而是为了让每一层都能被合适的测试方式覆盖。

四、先给一个最小服务骨架

先看最小骨架,不急着把全部细节讲满。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package probe

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)

type RunRequest struct {
Env string `json:"env"`
Targets []string `json:"targets"`
TimeoutSec int `json:"timeout_sec"`
}

type Result struct {
Target string `json:"target"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}

type Report struct {
Env string `json:"env"`
FinishedAt time.Time `json:"finished_at"`
Results []Result `json:"results"`
}

type Checker interface {
Check(ctx context.Context, env string, target string) (Result, error)
}

type ReportRepository interface {
Save(ctx context.Context, report Report) error
}

type ProbeService struct {
checker Checker
repo ReportRepository
}

func NewProbeService(checker Checker, repo ReportRepository) *ProbeService {
return &ProbeService{checker: checker, repo: repo}
}

func ValidateRequest(req RunRequest) error {
if strings.TrimSpace(req.Env) == "" {
return errors.New("env can not be empty")
}
if len(req.Targets) == 0 {
return errors.New("targets can not be empty")
}
if len(req.Targets) > 20 {
return errors.New("targets can not be larger than 20")
}
if req.TimeoutSec <= 0 || req.TimeoutSec > 30 {
return errors.New("timeout_sec must be between 1 and 30")
}
for _, target := range req.Targets {
if strings.TrimSpace(target) == "" {
return errors.New("target can not be empty")
}
}
return nil
}

func (s *ProbeService) Run(ctx context.Context, req RunRequest) (Report, error) {
if err := ValidateRequest(req); err != nil {
return Report{}, err
}

timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(req.TimeoutSec)*time.Second)
defer cancel()

report := Report{
Env: req.Env,
FinishedAt: time.Now(),
Results: make([]Result, 0, len(req.Targets)),
}

for _, target := range req.Targets {
result, err := s.checker.Check(timeoutCtx, req.Env, target)
if err != nil {
report.Results = append(report.Results, Result{
Target: target,
Status: "down",
Error: err.Error(),
})
continue
}
report.Results = append(report.Results, result)
}

if err := s.repo.Save(timeoutCtx, report); err != nil {
return Report{}, fmt.Errorf("save report: %w", err)
}

return report, nil
}

type FileReportRepository struct {
Dir string
}

func (r FileReportRepository) Save(ctx context.Context, report Report) error {
fileName := filepath.Join(r.Dir, report.Env+"-latest.json")
file, err := os.Create(fileName)
if err != nil {
return err
}
defer file.Close()

return json.NewEncoder(file).Encode(report)
}

这个版本先说明一件事:

  • ValidateRequest 是纯规则
  • ProbeService.Run 是业务编排
  • CheckerReportRepository 是依赖边界

一旦边界清楚,测试策略就自然清楚了。

五、为什么表驱动测试应该先从纯规则层开始

一提表驱动测试,最常见的初始理解是:

  • 就是 Go 的固定写法
  • 无非是写个切片循环
  • 看起来整齐一点,但和普通测试没本质区别

这理解太浅了。

表驱动测试真正的价值是:把一组同类规则和边界条件压缩成一张可枚举的决策表

最典型的适用场景有:

  • 参数校验
  • 状态流转
  • 错误分类
  • 金额、时间、阈值规则
  • 输入输出映射

这些逻辑如果散着写,很快就会变成:

  • case 漏一半
  • 改了一个条件,不知道还有哪些分支受影响
  • 失败时日志里只有一个模糊断言

ValidateRequest 正是最适合先做表驱动测试的地方。

六、给 ValidateRequest 写一版最小表驱动测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
func TestValidateRequest(t *testing.T) {
t.Parallel()

cases := []struct {
name string
req RunRequest
wantErr string
}{
{
name: "ok",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api", "user-api"},
TimeoutSec: 2,
},
},
{
name: "empty env",
req: RunRequest{
Targets: []string{"order-api"},
TimeoutSec: 2,
},
wantErr: "env can not be empty",
},
{
name: "empty targets",
req: RunRequest{
Env: "prod",
TimeoutSec: 2,
},
wantErr: "targets can not be empty",
},
{
name: "too many targets",
req: RunRequest{
Env: "prod",
Targets: make([]string, 21),
TimeoutSec: 2,
},
wantErr: "targets can not be larger than 20",
},
{
name: "invalid timeout",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api"},
TimeoutSec: 0,
},
wantErr: "timeout_sec must be between 1 and 30",
},
{
name: "blank target",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api", " "},
TimeoutSec: 2,
},
wantErr: "target can not be empty",
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

err := ValidateRequest(tc.req)
if tc.wantErr == "" && err != nil {
t.Fatalf("want nil error, got %v", err)
}
if tc.wantErr != "" {
if err == nil {
t.Fatalf("want error %q, got nil", tc.wantErr)
}
if err.Error() != tc.wantErr {
t.Fatalf("want error %q, got %q", tc.wantErr, err.Error())
}
}
})
}
}

这类测试的优点很直接:

  • 每条规则都能命名
  • 新增规则时只要补一行 case
  • 失败时能快速定位是哪个边界断了
  • 跑得快,适合高频执行

如果你的服务里校验规则很多,这一层测试通常是性价比最高的。

七、表驱动测试最容易写坏的地方

表驱动测试并不是“写成切片循环就自动高级”,它也有常见误区。

1. case 只有输入,没有为什么

坏例子:

1
2
3
4
cases := []RunRequest{
{Env: "", Targets: []string{"order"}},
{Env: "prod", Targets: nil},
}

这个写法的问题是:

  • 看不出每条 case 在验证什么
  • 一旦失败,信息量很弱

更稳的写法是显式写 name 和期望。

2. 在循环里忘了重新绑定变量

坏例子:

1
2
3
4
5
6
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateRequest(tc.req)
...
})
}

在旧版 Go 里,这类写法会踩循环变量捕获坑。
即使新版语义已经改善,显式写 tc := tc 仍然更稳,也更利于团队统一风格。

3. 一个 case 断言太多维度

如果一个 case 同时断言:

  • 错误消息
  • 返回结构
  • 调用次数
  • 日志内容
  • 持久化结果

那这个 case 一失败,排查范围会很大。
好的表驱动测试应该让每一组 case 主要围绕一类规则。

八、mock 之前,先把 test double 的关系说清楚

一提测试替身,它很容易被直接等同于 mock。
其实 mock 只是 test double 的一种。

更稳的区分方式是:

  1. stub
    给固定返回值,让测试能走下去

  2. fake
    给一个简化但可工作的实现,比如内存仓库、假客户端

  3. spy
    记录调用参数、调用次数,方便断言

  4. mock
    预先声明“应该怎样被调用”,再在测试里校验这些交互是否发生

在 Go 里,很多场景优先用 fakespy 就够了,不必一上来就生成大而复杂的 mock。

原因很简单:

  • Go 接口通常比较小
  • 手写 fake 往往比维护一套复杂 mock 配置更直接
  • 业务测试更应该关注结果和边界,而不是每一行调用顺序

九、服务层测试为什么通常用 fake 比纯 mock 更稳

先给 ProbeService 配两个最小 fake:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type fakeChecker struct {
results map[string]Result
errs map[string]error
calls []string
}

func (f *fakeChecker) Check(ctx context.Context, env string, target string) (Result, error) {
f.calls = append(f.calls, env+":"+target)
if err, ok := f.errs[target]; ok {
return Result{}, err
}
return f.results[target], nil
}

type fakeRepo struct {
saved []Report
saveErr error
}

func (f *fakeRepo) Save(ctx context.Context, report Report) error {
if f.saveErr != nil {
return f.saveErr
}
f.saved = append(f.saved, report)
return nil
}

有了这两个 fake,ProbeService 的大部分业务路径都能测清楚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
func TestProbeServiceRun(t *testing.T) {
t.Parallel()

cases := []struct {
name string
req RunRequest
checker *fakeChecker
repo *fakeRepo
wantErr string
wantStatuses []string
wantSaveCount int
}{
{
name: "all success",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api", "user-api"},
TimeoutSec: 2,
},
checker: &fakeChecker{
results: map[string]Result{
"order-api": {Target: "order-api", Status: "ok"},
"user-api": {Target: "user-api", Status: "ok"},
},
},
repo: &fakeRepo{},
wantStatuses: []string{"ok", "ok"},
wantSaveCount: 1,
},
{
name: "partial failure still save report",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api", "user-api"},
TimeoutSec: 2,
},
checker: &fakeChecker{
results: map[string]Result{
"order-api": {Target: "order-api", Status: "ok"},
},
errs: map[string]error{
"user-api": errors.New("downstream timeout"),
},
},
repo: &fakeRepo{},
wantStatuses: []string{"ok", "down"},
wantSaveCount: 1,
},
{
name: "save report failed",
req: RunRequest{
Env: "prod",
Targets: []string{"order-api"},
TimeoutSec: 2,
},
checker: &fakeChecker{
results: map[string]Result{
"order-api": {Target: "order-api", Status: "ok"},
},
},
repo: &fakeRepo{saveErr: errors.New("disk full")},
wantErr: "save report: disk full",
wantSaveCount: 0,
},
}

for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

svc := NewProbeService(tc.checker, tc.repo)
report, err := svc.Run(context.Background(), tc.req)

if tc.wantErr != "" {
if err == nil || err.Error() != tc.wantErr {
t.Fatalf("want error %q, got %v", tc.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("want nil error, got %v", err)
}
if len(report.Results) != len(tc.wantStatuses) {
t.Fatalf("want %d results, got %d", len(tc.wantStatuses), len(report.Results))
}
for i, wantStatus := range tc.wantStatuses {
if report.Results[i].Status != wantStatus {
t.Fatalf("result[%d] want %s, got %s", i, wantStatus, report.Results[i].Status)
}
}
if len(tc.repo.saved) != tc.wantSaveCount {
t.Fatalf("want save count %d, got %d", tc.wantSaveCount, len(tc.repo.saved))
}
})
}
}

这类测试在验证什么?

  • 非法请求是否被及时拒绝
  • 下游部分失败时,服务是否按预期汇总结果
  • 持久化失败时,错误是否被正确包装
  • 报告是否只在该保存的时候保存

这里更关心业务决策,而不是 Check 究竟第几行先被调、Save 第几毫秒被调。

十、什么时候真的需要 mock

虽然很多场景 fake 更稳,但 mock 不是完全没用。

下面这些场景,mock 会更有价值:

  1. 你必须验证交互约束
    例如“失败时绝不能调用发送通知接口”

  2. 你必须验证调用次数
    例如“重试最多只能发生两次”

  3. 你必须验证调用顺序
    例如“必须先加分布式锁,再写状态,再发消息”

  4. 依赖太重,不适合写 fake
    例如一个很复杂的第三方 SDK 客户端

比如要验证校验失败时根本不该触发下游检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type spyChecker struct {
called bool
}

func (s *spyChecker) Check(ctx context.Context, env string, target string) (Result, error) {
s.called = true
return Result{Target: target, Status: "ok"}, nil
}

func TestProbeServiceRun_InvalidRequest_ShouldNotCallChecker(t *testing.T) {
checker := &spyChecker{}
repo := &fakeRepo{}
svc := NewProbeService(checker, repo)

_, err := svc.Run(context.Background(), RunRequest{
Env: "",
Targets: []string{"order-api"},
TimeoutSec: 2,
})

if err == nil {
t.Fatal("want error, got nil")
}
if checker.called {
t.Fatal("checker should not be called")
}
if len(repo.saved) != 0 {
t.Fatal("repo should not save report")
}
}

这就是一个很典型的 spy/mock 场景。
断言交互约束,比断言返回值更关键。

十一、一个常见坏味道:把服务层测试写成“排练剧本”

第一次写 mock 测试时,很容易不自觉写成下面这种风格:

  • 预设 Check("order-api") 一定先被调用
  • 再预设 Check("user-api") 一定第二个被调用
  • 再预设 Save 一定第三个被调用
  • 任何调用顺序变化都算失败

这类测试的问题是:

  • 只要重构一下实现细节,测试就碎一片
  • 测试保护的是“当前写法”,不是“业务行为”
  • 一旦服务内部变并发,顺序断言会大量失真

测试应该优先保护什么?

  • 是否返回了正确结果
  • 是否做了正确错误传播
  • 是否遵守了必要交互边界

而不是把内部每一步都写成剧本。

十二、HTTP handler 为什么要单独做一层集成测试

很多 Go 服务只测 service,不测 handler。
最后最容易漏掉的恰好是这些:

  • JSON tag 写错
  • 字段缺失时返回码不对
  • Content-Type 没设
  • 路由参数和请求体绑定出问题
  • 错误信息没被正确转成 HTTP 响应

这就是为什么 handler 层要有自己的集成测试。
这里的“集成”不是说非得连真实数据库,而是指真实走一遍 HTTP 协议栈、序列化和响应写出逻辑

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func TestRunHandler(t *testing.T) {
service := NewProbeService(
&fakeChecker{
results: map[string]Result{
"order-api": {Target: "order-api", Status: "ok"},
},
},
&fakeRepo{},
)

handler := NewRunHandler(service)

reqBody := `{"env":"prod","targets":["order-api"],"timeout_sec":2}`
req := httptest.NewRequest(http.MethodPost, "/api/probe/run", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()

handler.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("want status 200, got %d", rec.Code)
}

var report Report
if err := json.NewDecoder(rec.Body).Decode(&report); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if len(report.Results) != 1 || report.Results[0].Status != "ok" {
t.Fatalf("unexpected response: %+v", report)
}
}

这一层测试重点不是业务分支覆盖率,而是:

  • 请求能不能被正确解析
  • 响应能不能被正确编码
  • 状态码和错误体是否符合预期

十三、真正的集成测试应该接真实适配器

如果 handler 还是 fake service,service 还是 fake checker,那它还不算完整的集成测试。
真正的集成测试应该至少让一段真实适配器链路跑起来。

比如这条链:

  • 真实 ProbeService
  • 真实 HTTPChecker
  • 真实 FileReportRepository
  • 下游用 httptest.NewServer 模拟 HTTP 服务
  • 文件输出用 t.TempDir() 隔离

这条链已经足够验证很多工程问题:

  • context 超时是否真正传到了 HTTP 请求
  • JSON 解码是否和下游协议一致
  • 文件输出是否真的写成功
  • 报告内容和落盘内容是否一致

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func TestProbeServiceIntegration(t *testing.T) {
downstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
_ = json.NewEncoder(w).Encode(Result{
Target: target,
Status: "ok",
})
}))
defer downstream.Close()

checker := NewHTTPChecker(downstream.URL, &http.Client{Timeout: time.Second})
repo := FileReportRepository{Dir: t.TempDir()}
svc := NewProbeService(checker, repo)

report, err := svc.Run(context.Background(), RunRequest{
Env: "prod",
Targets: []string{"order-api", "user-api"},
TimeoutSec: 2,
})
if err != nil {
t.Fatalf("run service failed: %v", err)
}
if len(report.Results) != 2 {
t.Fatalf("want 2 results, got %d", len(report.Results))
}

content, err := os.ReadFile(filepath.Join(repo.Dir, "prod-latest.json"))
if err != nil {
t.Fatalf("read report file failed: %v", err)
}
if !bytes.Contains(content, []byte(`"order-api"`)) {
t.Fatalf("unexpected file content: %s", string(content))
}
}

这类测试通常数量不需要很多,但不能缺。
因为它验证的是“这几个真实组件接起来之后,系统有没有跑通”。

十四、服务测试到底该怎么分层

如果把刚才这些测试方式压成一张最小策略表,大致会是这样:

  1. 规则层测试
    对象:参数校验、状态判断、映射逻辑
    方法:表驱动测试
    目标:高频、快速、覆盖边界

  2. 服务层测试
    对象:业务编排、错误传播、重试策略、降级策略
    方法:fake、stub、spy、最小 mock
    目标:验证决策是否正确

  3. 适配器集成测试
    对象:HTTP handler、HTTP client、repository、配置读取
    方法:httptest、临时目录、测试数据库、测试容器
    目标:验证协议和 I/O 是否真实成立

  4. 端到端测试
    对象:关键主流程
    方法:最少量真实环境验证
    目标:证明系统在近真实环境下可用

一套服务测试如果只有第 1 层,不够。
如果只剩第 3、4 层,也不够。
真正稳的是让不同层各自承担不同风险。

十五、什么时候不该写更多 mock,而应该补一条集成测试

下面这些信号一出现,通常说明你该补集成测试,而不是继续加 mock:

  1. 你已经在测试里手动模拟 JSON 编解码细节
  2. 你已经开始手动拼 HTTP status code、header、body
  3. 你在 mock 里模拟文件系统、网络连接或数据库事务
  4. 你发现测试通过,但一上线还是因为协议、配置或 SQL 失败
  5. 你维护 mock 的成本开始高于维护真实测试依赖

这时继续补 mock,收益会越来越低。
因为问题已经不在业务编排,而在真实适配器边界。

十六、flaky test 最常见的五类来源

Go 服务里的 flaky test,大多数都可以先从下面五类原因查起。

1. 时间问题

典型表现:

  • time.Sleep(10 * time.Millisecond) 等待异步完成
  • 本地过,CI 偶发超时
  • 秒级时间戳比较在快慢机器上表现不一致

更稳的做法:

  • 能用 channel 同步就不用盲等
  • 必须等待时,用 select + time.After
  • time.Now 抽成可注入时钟

2. 并发问题

典型表现:

  • t.Parallel() 一开就偶发失败
  • map 并发写
  • goroutine 没收口,影响后续测试

更稳的做法:

  • 共享状态加锁或完全隔离
  • 每个测试独占自己的 fake、临时目录和 server
  • contextWaitGroup 收干净 goroutine

3. 环境污染

典型表现:

  • 测试依赖全局环境变量
  • 测试共用固定端口
  • 测试共用一个 /tmp/report.json

更稳的做法:

  • t.Setenv
  • httptest.NewServer
  • t.TempDir

4. 隐式全局依赖

典型表现:

  • 直接改 http.DefaultClient
  • 直接改全局 logger
  • 直接改包级变量配置

更稳的做法:

  • 依赖显式注入
  • 每个测试自建 client、repo、logger
  • 避免测试间共享可变全局状态

5. 断言时机错误

典型表现:

  • 事件还没发生就断言
  • 事件发生了两次,却只偶尔捕到一次
  • 清理逻辑还没结束,测试已经退出

更稳的做法:

  • 明确“何时算完成”
  • 用可观察信号代替猜测
  • 把清理动作放进 t.Cleanup

十七、排查 flaky test 的一个实用顺序

排 flaky test 时,上来就看日志很容易越看越乱。
更稳的顺序通常是:

  1. 先判断它是不是并发相关
    先临时去掉 t.Parallel(),看失败是否消失

  2. 再判断它是不是时间相关
    time.Sleep 替换成显式同步或更长的超时,看行为是否稳定

  3. 再判断它是不是环境污染
    检查临时文件、环境变量、全局端口和全局客户端

  4. 再判断它是不是资源没清理
    检查 server、goroutine、file descriptor、context cancel 是否收口

  5. 最后才看业务断言本身
    确认是不是期望写错,或者断言粒度过细

如果要在本地放大问题,最常见的命令是:

1
2
go test ./... -run TestProbeServiceIntegration -count=100
go test ./... -run TestProbeServiceIntegration -race -count=50

第一条用于放大偶发失败。
第二条用于先排竞态。

十八、一个真实的小项目测试方案该长什么样

如果你手里现在有一个实际 Go 服务,比如:

  • 巡检服务
  • 接口回调服务
  • 任务分发服务
  • 配置发布服务

可以直接按下面这个最小方案落地:

  1. 给所有纯规则补表驱动测试
    例如校验、状态映射、错误分类、重试条件

  2. 给 service 层补 fake 测试
    至少覆盖成功、部分失败、全部失败、依赖失败、超时取消

  3. 给 handler 层补 httptest 测试
    至少覆盖正常请求、非法 JSON、参数缺失、服务错误映射

  4. 给关键 adapter 补集成测试
    例如 HTTP client + httptest.Server,repository + t.TempDir

  5. 只保留少量端到端测试
    只测最关键的主链路,不拿它做全量分支覆盖

这一套下来,通常已经能覆盖绝大多数线上前会暴露的问题。

十九、测试和验证时应该重点看什么

如果这篇文章里的小项目要在本地自验,建议至少做下面这些动作:

  1. 跑纯单元测试
    重点看规则边界是否都被 case 覆盖

  2. 跑服务层测试
    重点看错误传播、部分失败和持久化失败是否可观测

  3. 跑 handler 集成测试
    重点看请求和响应 JSON 是否与预期一致

  4. 跑适配器集成测试
    重点看超时、协议兼容和文件输出是否真的成立

  5. 跑竞态检测
    重点看 fake、共享状态和并发代码里有没有竞争

如果你的 CI 很慢,可以把测试按层拆开:

  • 默认提交跑规则层和服务层
  • 合并前跑适配器集成测试
  • 定时任务跑更慢的端到端测试

二十、这类测试方案的边界在哪里

这套方案很实用,但也有边界。

1. 不是所有代码都要强行表驱动

如果测试只有一个很清晰的场景,用普通测试函数反而更直。
不要为了“统一风格”把一切都塞进表里。

2. 不是所有依赖都值得抽接口

如果某个依赖只在一个地方用,且没有替换需求,强行抽接口只会增加样板代码。
接口应该服务测试和边界,不是为了“看起来解耦”。

3. mock 不是越多越专业

大量 mock 往往意味着:

  • 依赖边界设计过碎
  • 测试在保护实现细节
  • 重构成本被测试反向放大

4. 集成测试也不是越多越好

它们天然更慢、更重、更容易受环境影响。
真正重要的是把高风险真实边界测到,而不是把所有 case 都搬进去。

二十一、给自己留几道练习题

如果你想把这篇内容真正消化掉,可以做下面几道练习:

  1. ValidateRequest 增加 env 白名单规则
    要求:补完整表驱动测试

  2. ProbeService 加一个“失败目标最多重试一次”的策略
    要求:补 service 层 fake 测试,验证调用次数

  3. 给 handler 增加非法 Content-Type 的处理
    要求:补 httptest 测试

  4. FileReportRepository 改成同时输出摘要日志
    要求:思考这一层该写单元测试还是集成测试

  5. 人为制造一个 flaky test
    例如共享临时文件或用 time.Sleep 等待,再把它修掉

这几题做完,你对 Go 服务测试的理解会从“知道概念”变成“知道怎么落地”。

二十二、最后总结

Go 服务测试真正难的,从来不是会不会写 TestXxx,而是能不能把不同层的问题放到合适的测试里解决。

可以把这篇文章的核心结论收成四句话:

  1. 纯规则优先表驱动测试
  2. 服务编排优先 fake、stub、spy,必要时再上 mock
  3. 协议、I/O、序列化和真实交互必须用集成测试证明
  4. flaky test 不要靠运气,要按时间、并发、环境、全局状态和清理顺序系统排查

如果你把这套思路建立起来,后面不管是写 Go HTTP 服务、任务执行器还是测试平台组件,测试都不会再是一堆零散补丁,而会逐渐变成一套真正能托住工程演进的保护网。