5.3 难缠的边界:异步、时间与随机


5.3 难缠的边界:异步、时间与随机

本节摘要:让测试集体翻车的从来不是业务逻辑,而是三类不确定性:时间会流逝、随机会变化、异步会迟到。本节给三类各配一副药——注入时钟、固定种子、受控事件循环,核心思想与 3.3 一脉相承:不确定性源头全部收敛到可替换的接缝上。读完你应能把"偶尔红"的用例变成确定性用例,并给异步代码写出不靠猜的测试。

老结算系统的对账任务为什么没人敢碰?原因就藏在这三类里:它读 datetime.now() 决定对哪天的账,用 random 打散重试时机,还靠后台线程异步刷结果。测试它需要祈祷:祈祷跑的时刻正好命中旺季、祈祷随机数重放出当时的序列、祈祷线程赶在断言前干完活。老周把治疗思路一句话说完:别对抗不确定性,把它变成参数。

时间:注入时钟

时间的麻烦在于它是全局环境,读它的人实际依赖的是"整个世界"。治法是把读表动作收敛为一个可注入的协议:

from datetime import datetime from typing import Protocol class Clock(Protocol): def now(self) -> datetime: ... class SystemClock: # 生产环境:真表 def now(self) -> datetime: return datetime.now() class FakeClock: # 测试环境:停在任意时刻的表 def __init__(self, at: str): self._now = datetime.fromisoformat(at) def now(self) -> datetime: return self._now def advance(self, **kw): self._now += timedelta(**kw) # 测试里还能拨快时间 def is_peak_season(clock: Clock) -> bool: return clock.now().month in (11, 12)

业务代码从"读全局"改成"问注入的钟",测试立刻获得时间主权:

def test_peak_season_in_november(): assert is_peak_season(FakeClock("2026-11-20T10:00:00")) is True def test_peak_season_ends_on_december_last_second(): clock = FakeClock("2026-12-31T23:59:59") assert is_peak_season(clock) is True clock.advance(seconds=1) assert is_peak_season(clock) is False

第二条测试值得端详:跨年瞬间这种手工永远凑不巧的场景,拨表就测到了。不想改构造参数的老代码,可用 freezegun 一类工具在测试内冻结全局时间——能注入优先注入,冻结是给接缝手术前的过渡方案。

图:不确定性替身注入图

图:不确定性替身注入图

随机:固定种子与显式边界

随机的测试需求其实分两种,治法不同。要确定性(同一输入必得同一输出):注入随机源并固定种子,或干脆造一个按剧本出牌的假随机:

class ScriptedRandom: def __init__(self, script): self._seq = iter(script) def next_int(self, below=None): return next(self._seq) def test_retry_backoff_uses_scripted_jitter(): retry = RetryPolicy(jitter=ScriptedRandom([3, 7])) assert retry.next_delay_ms(base=100) == 103 assert retry.next_delay_ms(base=100) == 107

要覆盖面(想撞出奇怪的输入):别用裸随机碰运气,交给属性测试工具(Hypothesis 一类)——它生成输入、收缩反例、可复现失败,比手写 random.choice 靠谱一个量级。两者共同的红线:主测试路径里不许存在没有播种的随机。

异步:受控的事件循环

异步代码的假测试有一副标准面孔:time.sleep(0.5) 等一等再断言。机器快时浪费,机器慢时误判——等待时长靠猜的测试就是赌博。正解是把"等待"换成" await 到事件确实发生":

import pytest import asyncio @pytest.mark.asyncio async def test_order_stream_delivers_each_event(): bus = EventBus() seen = [] async def collect(evt): seen.append(evt) bus.subscribe(collect) await bus.publish(OrderEvent("created", "o-1")) await bus.publish(OrderEvent("paid", "o-1")) await asyncio.sleep(0) # 让出执行权,冲掉悬挂任务——不是等待时长 assert [e.type for e in seen] == ["created", "paid"]

要点有三:用 pytest-asyncio 把事件循环交给夹具管理;断言前让协程确实跑完(await 关键调用、或让出执行权),而不是睡一段拍脑袋的时长;真要多线程等结果,轮询加截止时间(deadline 内反复检查,超时即红),绝不用裸 sleep。

⚠️ "本地是绿的"对异步用例毫无意义——时序问题专挑慢环境与高峰期发作。异步用例的可信度标准只有一条:反复跑成百上千遍,零闪红。CI 里给这类用例开重复执行的插件档(第 6 章)是常规操作。

💡 回望 5.2 的老对账任务:接缝手术把 datetime.now() 换成注入时钟、随机换剧本、线程换受控队列之后,"没人敢碰"变成了"每轮循环都碰"——地形没变,主权变了。

边界测试三招

  • 时间:注入 Clock 协议,FakeClock 可拨可停,跨年月末随手测。
  • 随机:确定性用种子或剧本随机源,覆盖面交给属性测试工具。
  • 异步:await 到事件确实发生再断言,轮询加截止时间,拒绝裸 sleep。
  • 三招共用一个思想:把全局环境收敛成接缝,不确定性变成参数。
  • 下一节换个说法讲同一套节律:让业务方也读得懂的 BDD 与 ATDD。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U