ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go定时任务测试实战:用Mock Clock让时间可控

Go定时任务测试实战:用Mock Clock让时间可控 做后端的同学应该都有这种经历功能逻辑写好了单元测试也加了一遇到定时任务就只能“眼测”——把间隔改成1分钟盯着日志等触发或者干脆在本地人工敲一下接口。反正只要和time包沾边测试就变得又慢又随机还经常在 CI 里无缘无故挂掉。今天这篇就专门聊聊 golang 定时任务怎么测。我会从最核心的难点讲起给出一套可以落到代码里的时间控制方案再用重试任务、cron 表达式任务做实战拆解最后把并发、超时、panic 这类场景也过一遍。适合正在写 Go 服务的后端开发、测试工程师以及所有被定时任务折磨过的同学。先说全文的核心观点定时任务测试测的不是时间而是“时间到的时候你的逻辑有没有被正确调用”。只要围绕这句话去做设计后面的一切都会顺起来。1. 定时任务为什么这么难测1.1 时间才是最大的隐藏依赖写普通业务的时候我们的依赖很明确数据库、Redis、消息队列、外部接口这些都可以通过 mock 或 fake 替换掉。但定时任务多了一个“依赖”就是时间本身。time.Now()、time.After()、time.NewTicker()、time.Sleep()这些调用散落在任务代码里直接跟真实时钟绑定。你想构造一个“5 分钟后触发”的场景难道真的在测试里time.Sleep(5 * time.Minute)吗完全不现实。哪怕你真有耐心等 5 分钟CI 上多个测试这样搞下来整个流水线直接废掉。更麻烦的是如果业务逻辑里混着time.Sleep(30 * time.Second)这种重试等待测试会变得更加不可预测。时间一长人就想放弃测试直接靠线上“裸奔”。1.2 定时任务测试要解决的三个矛盾结合我自己的经验定时任务测试难核心是三个矛盾。第一真实时间和测试时间的矛盾。业务代码跑在真实时钟上测试却希望时间“快进”或者“暂停”这两者天然冲突。第二调度逻辑和业务逻辑耦合的矛盾。很多人的定时任务写成一个大函数启动 ticker然后循环里做业务。测试的时候想验证业务却被 ticker 卡住想验证调度业务又在真跑结果两边都测不透。第三异步执行和确定性断言的矛盾。定时任务一般是 goroutine 里跑的触发后什么时候执行完你拿不到一个明确的返回值。测试里如果只是“过一秒然后断言”大概率偶发失败跑十次挂一次最折磨人。所以我们要做的不是跟这三个矛盾硬刚而是从设计层面把它们拆开。2. 让时间可控Clock 抽象与 fake timer2.1 最直接的做法为什么不行有人会说那我测试里不用真实间隔把定时任务的时间间隔改成 100 毫秒跑起来等触发不就行了表面上可行但问题不少。100 毫秒的间隔在本地环境可能稳定CI 机器一旦负载高goroutine 调度一卡触发时间可能延迟几百毫秒你按 100 毫秒等待去断言就会挂。反过来如果你为了稳妥等 2 秒那每测一个周期就慢 2 秒测试一多直接爆掉。这还只解决了“快”的问题完全没有解决“确定性”的问题——你不能精准控制在“第 3 次触发”的时候去断言。所以正确方向不是把真实时间调小而是让测试环境里根本不存在“真实时间”这个概念。2.2 用 Clock 接口隔断 time 包依赖办法其实很朴素业务代码里不要直接调用time.Now()、time.NewTicker()这些函数而是通过一个叫Clock的接口去调。type Clock interface { Now() time.Time After(d time.Duration) -chan time.Time NewTicker(d time.Duration) *Ticker NewTimer(d time.Duration) *Timer Sleep(d time.Duration) }生产环境传一个真实时钟测试环境传一个 mock 时钟。mock 时钟的时间轴完全由测试代码控制想快进多久就快进多久想停在哪个时刻就停在哪个时刻。这里特别注意接口里的*Ticker和*Timer最好用自定义类型而不是直接返回*time.Ticker。因为time.Ticker是具体类型内部持有真实定时器mock 起来非常麻烦。很多同学第一次写 Clock 接口就在这里翻车。如果你不想自己造轮子直接用社区里比较成熟的库github.com/benbjohnson/clock。它把这套接口定义好了真实时钟用clock.New()mock 时钟用clock.NewMock()mock 上有个Add(d)方法可以手动推进时间。下面实战我都基于这个库来写。2.3 Mock Clock 的实战代码来看一个最典型的例子日志清理任务每小时跑一次把过期数据删掉。平时大家容易写成这样func StartCleanup(ctx context.Context) { ticker : time.NewTicker(1 * time.Hour) defer ticker.Stop() for { select { case -ticker.C: deleteExpired(ctx) case -ctx.Done(): return } } }要测它要么等一个小时要么把 1 小时改成 100 毫秒然后碰运气。现在我改成注入 Clocktype CleanupWorker struct { interval time.Duration clock clock.Clock repo *Repository } func NewCleanupWorker(interval time.Duration, repo *Repository, clk clock.Clock) *CleanupWorker { return CleanupWorker{ interval: interval, clock: clk, repo: repo, } } func (w *CleanupWorker) Run(ctx context.Context) { ticker : w.clock.NewTicker(w.interval) defer ticker.Stop() for { select { case -ticker.C: w.repo.DeleteExpired(ctx) case -ctx.Done(): return } } }对应的测试可以这样写type fakeRepo struct { cleaned chan struct{} } func (r *fakeRepo) DeleteExpired(ctx context.Context) error { select { case r.cleaned - struct{}{}: default: } return nil } func TestCleanupWorker_Run(t *testing.T) { mock : clock.NewMock() repo : fakeRepo{cleaned: make(chan struct{}, 1)} w : NewCleanupWorker(time.Hour, repo, mock) ctx, cancel : context.WithCancel(context.Background()) defer cancel() go w.Run(ctx) // 未到时间不应该触发清理 select { case -repo.cleaned: t.Fatal(还不到一小时不应该触发清理) default: } // 推进一小时应触发一次清理 mock.Add(time.Hour) select { case -repo.cleaned: case -time.After(time.Second): t.Fatal(推进一小时后应触发清理但没等到) } // 再推进一小时应再次触发 mock.Add(time.Hour) select { case -repo.cleaned: case -time.After(time.Second): t.Fatal(推进到第二个周期应再次触发清理) } }这个测试有几个关键点。第一整个测试跑下来毫秒级没有真实等待。mock.Add(time.Hour)只是把 mock 时钟的内部游标往前推触发逻辑由库内部派发跟真实时间完全无关。第二fakeRepo.cleaned是一个带缓冲的 channel用来“确认”清理逻辑真的被调用了。因为Run在独立 goroutine 里跑触发是异步的我们不能在mock.Add之后直接断言必须等一个信号。第三未触发前的default分支很重要。它确保测试开始时是干净的避免上一次触发的残留信号干扰判断。这个细节容易漏但漏了之后测试会莫名其妙“提前通过”比失败还难查。3. 实战一个重试定时任务从坏到好3.1 需求每 5 分钟重试失败任务假设我们现在有个订单系统任务表里记录了发送消息失败的记录。需要每 5 分钟扫一次把重试次数小于 3 的捞出来重新执行重试次数超过 3 的直接标记为死信。这是非常典型的定时任务。没有抽象之前代码大概是func StartRetryLoop(ctx context.Context) { ticker : time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case -ticker.C: tasks : loadFailedTasks() for _, task : range tasks { if task.RetryCount 3 { markDead(task.ID) continue } err : retryOne(task) if err ! nil { markRetryCount(task.ID, task.RetryCount1) } else { markSuccess(task.ID) } } case -ctx.Done(): return } } }这里的问题非常明显定时触发和业务逻辑完全揉在一个函数里。你想测“重试三次后标记死信”的逻辑必须先启动 ticker再等 5 分钟或者为测试临时改函数里的间隔常量。不管哪种都是在跟时间搏斗。3.2 第一步把业务和调度分开重构思路很简单把 ticker 循环里的那一大坨抽成一个独立方法比如叫CheckAndRetry(ctx)。它负责“加载失败任务、判断重试次数、执行重试、更新状态”。调度部分只负责“每隔 5 分钟调用一次CheckAndRetry”。type RetryWorker struct { interval time.Duration maxRetry int clock clock.Clock store TaskStore } // CheckAndRetry 是纯业务逻辑不依赖任何定时器 func (w *RetryWorker) CheckAndRetry(ctx context.Context) { tasks, _ : w.store.LoadFailed(ctx) for _, task : range tasks { if task.RetryCount w.maxRetry { w.store.MarkDead(ctx, task.ID) continue } if err : w.store.Retry(ctx, task.ID); err ! nil { w.store.MarkRetryCount(ctx, task.ID, task.RetryCount1) continue } w.store.MarkSuccess(ctx, task.ID) } } // Run 只负责调度 func (w *RetryWorker) Run(ctx context.Context) { ticker : w.clock.NewTicker(w.interval) defer ticker.Stop() for { select { case -ticker.C: w.CheckAndRetry(ctx) case -ctx.Done(): return } } }这样拆完之后CheckAndRetry本身变成了一个普通函数可以直接构造数据去测。你不需要等待任何时间伪造一批任务塞进去断言最终状态变化就好。Run的测法跟刚才清理任务的例子一样用 mock clock 推时间然后通过TaskStore的 fake 实现来判断CheckAndRetry是否被调用。3.3 第二步用 Mock Clock 测调度Ticker 触发是一个异步派发过程mock.Add()之后触发可能不会立刻发生。所以我在 fake store 里加了一个信号 channel记录每次CheckAndRetry的调用。type fakeStore struct { retried chan string failed []Task } func (s *fakeStore) LoadFailed(ctx context.Context) ([]Task, error) { return s.failed, nil } func (s *fakeStore) Retry(ctx context.Context, id string) error { s.retried - id return nil }测试里先启动Run然后mock.Add(5 * time.Minute)再等retriedchannel 里的值。这样调度触发被验证了业务执行也被验证了两边互不干扰。另外要注意Run里用了defer cancel()只是保证测试函数结束前取消 context但 goroutine 到底有没有退出你并没有准确感知。所以我在实际项目里通常会在测试最后加一个等 goroutine 退出的同步点用ctx.Done()配合select去收尾避免泄漏。3.4 第三步测重试的退避策略上面这个任务只是“每 5 分钟扫一次”还没有退避。但很多重试任务会做“失败后等 1 秒、2 秒、4 秒再重试”的指数退避。这时候测试就更有意思了。假设你的退避计算函数长这样func (w *RetryWorker) NextDelay(retryCount int) time.Duration { if retryCount 0 { return time.Second } if retryCount 5 { return 10 * time.Minute } return time.Second * time.Duration(1uint(retryCount-1)) // 1s, 2s, 4s, 8s, 16s }这个函数是纯函数直接测就好不需要任何 mockfunc TestNextDelay(t *testing.T) { w : RetryWorker{} cases : []struct { name string count int want time.Duration }{ {首次重试, 1, time.Second}, {第二次重试, 2, 2 * time.Second}, {第三次重试, 3, 4 * time.Second}, {第六次重试封顶, 6, 10 * time.Minute}, } for _, tc : range cases { t.Run(tc.name, func(t *testing.T) { if got : w.NextDelay(tc.count); got ! tc.want { t.Fatalf(NextDelay(%d) %v, want %v, tc.count, got, tc.want) } }) } }你可能觉得这太简单了。对但它恰恰是我要说的重点只要把时间相关的东西抽成小函数它们就成了普通逻辑能用最朴素的单测覆盖。真正的难点从来不是退避公式而是退避逻辑和定时循环缠在一起导致你没法单独验证。4. Cron 表达式任务的测试技巧4.1 测表达式本身是否“长对”了很多项目用的是robfig/cron这类库任务注册方式类似这样c : cron.New() c.AddFunc(0 0 3 * * *, cleanup) c.Start()这种写法带来的最大隐患是表达式有没有写对光靠眼睛看不出来。尤其新手容易把五段和六段混掉robfig/cron默认支持五段0 0 3 * * *其实是六段带秒有些人只传五段就报错。我的做法是给 cron 表达式写个解析测试。cron库里每个Schedule接口都有Next(time.Time)方法可以算出给定时间之后的下一次触发时间。这个特性非常适合做断言。func TestCronExpression(t *testing.T) { parser : cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) sched, err : parser.Parse(0 0 3 * * *) if err ! nil { t.Fatalf(解析失败: %v, err) } now : time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) next : sched.Next(now) want : time.Date(2024, 1, 1, 3, 0, 0, 0, time.UTC) if !next.Equal(want) { t.Fatalf(下一次触发 %v, want %v, next, want) } }每配置一个 cron 表达式就补一个这样的测试。表达式写错、时区搞错、格式传错测试立刻发现根本不用等到线上凌晨三点看它到底跑没跑。4.2 测任务注册与触发有人问那我想测“这个 cron 任务确实被注册进 scheduler 了”怎么测cron.Cron提供了Entries()方法返回所有已注册的 entry。每个 entry 里带Schedule可以拿来做断言。func TestCronJobsRegistered(t *testing.T) { c : cron.New() id, err : c.AddFunc(0 0 3 * * *, cleanup) if err ! nil { t.Fatal(err) } entry : c.Entry(id) if entry.Schedule nil { t.Fatal(任务没有关联调度器) } // 从 entry 的 Schedule 推导下一次执行时间 next : entry.Schedule.Next(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)) want : time.Date(2024, 6, 1, 3, 0, 0, 0, time.UTC) if !next.Equal(want) { t.Fatalf(got %v, want %v, next, want) } }这里并不需要真的调用c.Start()AddFunc之后 entry 里已经有 Schedule 了。所以这个测试是纯同步的非常快。4.3 端到端测试的取舍还有一种更激进的测法直接把 cron 的间隔调成很小比如每秒触发一次然后注册一个写 channel 的任务启动c.Start()等 channel 里有信号就断言触发成功。这种测法跑起来其实也能通过但我不太推荐作为主要手段。原因是它仍然依赖真实时间CI 负载一高就容易抖动。它能证明“cron 库本身能调度”但 cron 库的调度能力已经由库作者自己测过了不需要你来验证。我更倾向于把 cron 测试分成两层表达式解析和注册用上面的同步断言真正执行业务逻辑则用第二节的 Mock Clock 方案。两层合起来覆盖已经足够完整而且速度稳定。5. 并发、超时与故障注入5.1 goroutine 泄漏检测定时任务测试里最容易忽略的问题是 goroutine 泄漏。一个Run(ctx)启动了 ticker 循环测试结束时 context 取消了但有些子 goroutine 没退出它们会一直残留在测试进程里。如果整个测试文件只有一个用例泄漏影响还不明显。但多个用例叠在一起残留 goroutine 越来越多最后能把你机器资源吃满。而且这种问题特别隐蔽正常跑不过写-race跑也未必报错。我建议在每个定时任务模块里都接上go.uber.org/goleakfunc TestMain(m *testing.M) { goleak.VerifyTestMain(m) }这么一接只要任何测试结束后还有 goroutine 没退出TestMain就会直接报错。虽然一开始接入时会炸出一堆历史遗留问题但修完之后你的调度代码会变得非常干净——因为所有 goroutine 都必须能可预期地退出。5.2 等待异步任务完成的标准姿势定时任务触发之后业务逻辑在后台异步执行。测试里怎么确认“它执行完了”最常见的问题是只等固定时间比如time.Sleep(500 * time.Millisecond)然后断言。这种做法我说过很多次不稳定。标准做法是用 channel 做信号加上超时保护done : make(chan error, 1) go func() { done - w.retryOne(ctx, task) }() select { case err : -done: if err ! nil { t.Fatalf(retryOne 返回错误: %v, err) } case -time.After(2 * time.Second): t.Fatal(retryOne 超时未返回) }这里的 2 秒是“测试最大容忍时间”超过它说明逻辑出问题了而不是为了等业务执行完。这个语义完全不同。另一个经验是fake 实现里的 signal channel 最好带缓冲大小给 1 或 2。不然测试逻辑跑得比断言逻辑快channel 没人接收发送方就阻塞了。带缓冲可以避免这一类偶发阻塞导致假失败。5.3 panic 与失败重试场景定时任务里如果有一个 job panic 了最坏情况是整个服务崩掉。所以很多人会在任务入口套一层 recover记录日志不让 panic 扩散。func (w *RetryWorker) SafeRun(ctx context.Context, fn func(ctx context.Context)) { defer func() { if r : recover(); r ! nil { w.logger.Errorf(job panic: %v, r) } }() fn(ctx) }测试这种 recover 逻辑我一般直接喂一个会 panic 的函数func TestSafeRunRecoversPanic(t *testing.T) { var logBuf bytes.Buffer w : RetryWorker{logger: log.New(logBuf, , 0)} w.SafeRun(context.Background(), func(ctx context.Context) { panic(oops) }) if !strings.Contains(logBuf.String(), job panic) { t.Fatalf(日志中没有记录 panic实际输出: %s, logBuf.String()) } }这个测试跑完如果SafeRun没有正确 recover整个测试进程直接崩。所以它测的就是“任务 panic 时不会让服务挂掉”这个核心特性没有比这更直接的了。失败重试场景也不难测。伪造一个永远返回错误的依赖跑一次CheckAndRetry断言重试计数被正确累加、任务没有被错误地标记为成功。这些都不涉及时间控制只要业务和调度拆干净了都是普通单测。5.4 随机延迟测试最后说一下随机性。有些任务会故意加随机延迟防止多个实例同时触发“惊群”。比如func (w *Worker) delayWithJitter(base time.Duration) time.Duration { return base time.Duration(rand.Int63n(int64(base))) }这种函数没法断言具体返回值但可以断言范围func TestDelayWithJitter(t *testing.T) { base : 10 * time.Second for i : 0; i 100; i { d : w.delayWithJitter(base) if d base || d 2*base { t.Fatalf(delay 超出范围: %v, d) } } }定时任务往往会手滑写成rand.Int63n(int64(2*base))或者rand.Int63n(int64(base))加 base 时少算边界这类小 bug 靠代码 review 不太好发现靠循环跑一百次断言就很稳。6. 常见问题与排查技巧实录6.1 排查速查表我在实际项目里见到的定时任务测试问题大部分都集中在下面这张表里现象常见原因排查方向测试最少跑 1 分钟起步代码里直接写死time.Sleep/time.After全局搜索这两个调用改成注入的 clock单个测试能过整套全挂任务 goroutine 没退出泄漏到下一个用例每个测试用context.WithCancel并在t.Cleanup里 cancelmock clock 推进了但回调没触发业务代码仍然用time包没走注入的 clock检查NewTicker、After的调用方mock clock Add 后断言偶发失败异步派发没结束就断言用 signal channel 等回调执行完成再断言cron 表达式在线上不触发表达式写错、秒字段缺失、时区不一致用Schedule.Next做单测显式指定 location测试偶发 panic进程直接崩goroutine 里的 panic 没有 recover任务入口统一包SafeRun补一个 panic 用例6.2 我踩过的三个真实坑先说第一个。之前在公司重构一个支付重试任务我把NewTicker换成了注入的 clock但漏了某个子函数里的time.Sleep(2 * time.Second)。结果测试里mock.Add倒是很快把 ticker 推过去了业务函数卡在真实的time.Sleep上整个测试照样慢。排查方法还是靠全局搜索time.Sleep一条一条看最后才发现漏网之鱼。后来我养成了习惯定时任务模块里除了 main 函数和测试代码一律不允许直接出现time包调用。第二个坑是 mock clock 的异步派发。早期写测试时我mock.Add(5 * time.Minute)之后立刻断言 fake store 里的字段结果偶发失败。一开始还以为是数据竞争后来才发现 ticker 的回调是异步派发的mock.Add返回时回调可能还没执行。改成 signal channel 之后测试就稳了。第三个坑和t.Parallel()有关。我曾经给多个定时任务用例加了t.Parallel()结果它们共享一个全局的 fake store 状态执行顺序不确定测试一会儿过一会儿挂。排查了很久才发现是并发执行导致状态互相污染。后来定时任务的测试我基本不用t.Parallel()或者在每个用例里用独立的 fake 实例。这些教训总结成一句话定时任务测试出问题八成不是测试代码写错而是被测代码里混着没用抽象的时间调用、异步信号没同步干净、或者共享了不该共享的状态。最后分享一个我一直用的技巧如果你接手的项目里有很多历史定时任务暂时没时间一个个重构但又想补测试有个低成本办法把所有任务 handler 抽成纯业务函数先测纯业务函数调度的部分用 mock clock 只补一条“确保到了时间会调用”的测试。不要一上来就想把每个定时任务都做成完美的可注入架构那样改动太大风险反而高。我自己的体会是定时任务测试的本质就是把时间从代码里“摘”出来。时间去掉了它就变成了一个普通函数用最朴素的单测就能覆盖时间保留着测试就会永远伴随随机失败和漫长的等待。大家写代码的时候多问自己一句“这个函数的执行结果测试时能不能在 1 毫秒内得到”——如果能你的定时任务设计基本就没问题了。
返回列表