学 Go 学到服务开发阶段时,会很快遇到一类很典型的困惑:
单元测试会写一点,但一到真实服务就不知道从哪下手
听过表驱动测试,也照着写过 cases := []struct{...},但总觉得只是形式
看到别人说要 mock,又不知道 Go 里到底该不该大量 mock
集成测试一跑就慢,一慢就不想跑
偶尔还有一两条测试本地能过,CI 上却随机失败
这类问题通常不是因为测试框架不会,而是因为测试分层和依赖边界没有建立起来 。
Go 服务测试真正要先看清的是:
哪些逻辑可以纯函数化,用表驱动测试快速兜住
哪些逻辑需要依赖替身,验证业务分支和错误路径
哪些地方必须接真实适配器,才能证明工程链路真的成立
哪些测试不稳定,其实不是“运气不好”,而是代码本身有竞争、时间、环境或资源边界问题
这一篇就围绕一个实际小场景来讲:做一个最小的服务巡检任务服务 。
这个服务要做几件事:
接收一批待巡检目标
校验环境、目标数量和超时参数
调用下游健康检查接口
汇总巡检结果
把结果落盘保存
通过 HTTP 接口对外提供执行入口
这个场景不大,但足够把 Go 服务测试里最常见的几层一次串起来。
一、这篇文章要解决什么问题 读完这一篇,应该能独立回答这些问题:
表驱动测试到底适合解决什么问题
Go 里的 mock、fake、stub、spy 分别是什么关系
服务层测试为什么不该全部依赖真实下游
为什么也不能把所有测试都做成 mock 测试
HTTP handler、service、repository、下游 client 应该分别怎么测
集成测试到底在证明什么
flaky test 通常从哪几类问题开始排查
如果这些问题能答清,后面你写 Go HTTP 服务、任务执行器、调度器、测试平台组件时,测试结构会稳很多。
二、先把一句话原则说清楚 Go 服务测试最稳的做法,不是押注某一种测试方式,而是按变化速度、依赖成本和故障半径 分层:
纯规则层 优先写表驱动测试,跑得快、反馈准
服务编排层 优先用 fake 或最小 mock,验证业务决策、错误传播和调用边界
适配器层 用 httptest、临时目录、测试数据库、测试容器去做集成测试,验证协议、序列化、I/O、配置和真实交互
端到端层 数量最少,只验证关键主链路,不负责覆盖所有分支
如果一上来就把所有逻辑都扔进集成测试,反馈会很慢。 如果反过来把所有东西都 mock 掉,又会得到一堆“假绿灯”。
三、先看这篇文章里的小项目 假设现在要做一个最小巡检任务服务,暴露一个接口:
POST /api/probe/run
请求体:
1 2 3 4 5 { "env" : "prod" , "targets" : [ "order-api" , "user-api" ] , "timeout_sec" : 2 }
服务要做的动作是:
校验 env
校验目标列表不能为空,且不能超过上限
用 context.WithTimeout 控制整批巡检超时
调用下游健康检查器获取每个目标状态
生成报告
把报告通过 repository 落盘
把结果 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 probeimport ( "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 是业务编排
Checker 和 ReportRepository 是依赖边界
一旦边界清楚,测试策略就自然清楚了。
五、为什么表驱动测试应该先从纯规则层开始 一提表驱动测试,最常见的初始理解是:
就是 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 的一种。
更稳的区分方式是:
stub 给固定返回值,让测试能走下去
fake 给一个简化但可工作的实现,比如内存仓库、假客户端
spy 记录调用参数、调用次数,方便断言
mock 预先声明“应该怎样被调用”,再在测试里校验这些交互是否发生
在 Go 里,很多场景优先用 fake 或 spy 就够了,不必一上来就生成大而复杂的 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 会更有价值:
你必须验证交互约束 例如“失败时绝不能调用发送通知接口”
你必须验证调用次数 例如“重试最多只能发生两次”
你必须验证调用顺序 例如“必须先加分布式锁,再写状态,再发消息”
依赖太重,不适合写 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)) } }
这类测试通常数量不需要很多,但不能缺。 因为它验证的是“这几个真实组件接起来之后,系统有没有跑通”。
十四、服务测试到底该怎么分层 如果把刚才这些测试方式压成一张最小策略表,大致会是这样:
规则层测试 对象:参数校验、状态判断、映射逻辑 方法:表驱动测试 目标:高频、快速、覆盖边界
服务层测试 对象:业务编排、错误传播、重试策略、降级策略 方法:fake、stub、spy、最小 mock 目标:验证决策是否正确
适配器集成测试 对象:HTTP handler、HTTP client、repository、配置读取 方法:httptest、临时目录、测试数据库、测试容器 目标:验证协议和 I/O 是否真实成立
端到端测试 对象:关键主流程 方法:最少量真实环境验证 目标:证明系统在近真实环境下可用
一套服务测试如果只有第 1 层,不够。 如果只剩第 3、4 层,也不够。 真正稳的是让不同层各自承担不同风险。
十五、什么时候不该写更多 mock,而应该补一条集成测试 下面这些信号一出现,通常说明你该补集成测试,而不是继续加 mock:
你已经在测试里手动模拟 JSON 编解码细节
你已经开始手动拼 HTTP status code、header、body
你在 mock 里模拟文件系统、网络连接或数据库事务
你发现测试通过,但一上线还是因为协议、配置或 SQL 失败
你维护 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
用 context 和 WaitGroup 收干净 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 时,上来就看日志很容易越看越乱。 更稳的顺序通常是:
先判断它是不是并发相关 先临时去掉 t.Parallel(),看失败是否消失
再判断它是不是时间相关 把 time.Sleep 替换成显式同步或更长的超时,看行为是否稳定
再判断它是不是环境污染 检查临时文件、环境变量、全局端口和全局客户端
再判断它是不是资源没清理 检查 server、goroutine、file descriptor、context cancel 是否收口
最后才看业务断言本身 确认是不是期望写错,或者断言粒度过细
如果要在本地放大问题,最常见的命令是:
1 2 go test ./... -run TestProbeServiceIntegration -count=100 go test ./... -run TestProbeServiceIntegration -race -count=50
第一条用于放大偶发失败。 第二条用于先排竞态。
十八、一个真实的小项目测试方案该长什么样 如果你手里现在有一个实际 Go 服务,比如:
巡检服务
接口回调服务
任务分发服务
配置发布服务
可以直接按下面这个最小方案落地:
给所有纯规则补表驱动测试 例如校验、状态映射、错误分类、重试条件
给 service 层补 fake 测试 至少覆盖成功、部分失败、全部失败、依赖失败、超时取消
给 handler 层补 httptest 测试 至少覆盖正常请求、非法 JSON、参数缺失、服务错误映射
给关键 adapter 补集成测试 例如 HTTP client + httptest.Server,repository + t.TempDir
只保留少量端到端测试 只测最关键的主链路,不拿它做全量分支覆盖
这一套下来,通常已经能覆盖绝大多数线上前会暴露的问题。
十九、测试和验证时应该重点看什么 如果这篇文章里的小项目要在本地自验,建议至少做下面这些动作:
跑纯单元测试 重点看规则边界是否都被 case 覆盖
跑服务层测试 重点看错误传播、部分失败和持久化失败是否可观测
跑 handler 集成测试 重点看请求和响应 JSON 是否与预期一致
跑适配器集成测试 重点看超时、协议兼容和文件输出是否真的成立
跑竞态检测 重点看 fake、共享状态和并发代码里有没有竞争
如果你的 CI 很慢,可以把测试按层拆开:
默认提交跑规则层和服务层
合并前跑适配器集成测试
定时任务跑更慢的端到端测试
二十、这类测试方案的边界在哪里 这套方案很实用,但也有边界。
1. 不是所有代码都要强行表驱动 如果测试只有一个很清晰的场景,用普通测试函数反而更直。 不要为了“统一风格”把一切都塞进表里。
2. 不是所有依赖都值得抽接口 如果某个依赖只在一个地方用,且没有替换需求,强行抽接口只会增加样板代码。 接口应该服务测试和边界,不是为了“看起来解耦”。
3. mock 不是越多越专业 大量 mock 往往意味着:
依赖边界设计过碎
测试在保护实现细节
重构成本被测试反向放大
4. 集成测试也不是越多越好 它们天然更慢、更重、更容易受环境影响。 真正重要的是把高风险真实边界测到,而不是把所有 case 都搬进去。
二十一、给自己留几道练习题 如果你想把这篇内容真正消化掉,可以做下面几道练习:
给 ValidateRequest 增加 env 白名单规则 要求:补完整表驱动测试
给 ProbeService 加一个“失败目标最多重试一次”的策略 要求:补 service 层 fake 测试,验证调用次数
给 handler 增加非法 Content-Type 的处理 要求:补 httptest 测试
把 FileReportRepository 改成同时输出摘要日志 要求:思考这一层该写单元测试还是集成测试
人为制造一个 flaky test 例如共享临时文件或用 time.Sleep 等待,再把它修掉
这几题做完,你对 Go 服务测试的理解会从“知道概念”变成“知道怎么落地”。
二十二、最后总结 Go 服务测试真正难的,从来不是会不会写 TestXxx,而是能不能把不同层的问题放到合适的测试里解决。
可以把这篇文章的核心结论收成四句话:
纯规则优先表驱动测试
服务编排优先 fake、stub、spy,必要时再上 mock
协议、I/O、序列化和真实交互必须用集成测试证明
flaky test 不要靠运气,要按时间、并发、环境、全局状态和清理顺序系统排查
如果你把这套思路建立起来,后面不管是写 Go HTTP 服务、任务执行器还是测试平台组件,测试都不会再是一堆零散补丁,而会逐渐变成一套真正能托住工程演进的保护网。