bigywater.tech
Python · 105

测试中的时间、随机数和网络隔离

测试最怕「不确定」:现在几点、随机数是多少、网络通不通。这三样不受你控制,测试就会间歇失败。这篇把三类问题各用一个真实实验讲清:怎么让时间、随机、网络变得可控,以及 patch 最容易踩的那个坑。

8 / 24
不控制时间的测试,一天 24 个整点里通过的个数
0.60 s → 0.0003 s
连接不可达地址:无保护(超时)与「禁外网」fixture 下的耗时
1.12 s → 0.07 s
带两次重试等待的测试:真实 sleep 与 patch 掉 sleep

验证一:依赖「现在」的测试是不稳定的

shop.py def is_business_hours(): h = datetime.datetime.fromtimestamp(time.time()).hour return 9 <= h < 17
实测 · 本机执行输出
不打补丁的测试 assert is_business_hours() 在一天 24 个整点里会通过的时刻: [9, 10, 11, 12, 13, 14, 15, 16]
  → 24 小时里 8 小时通过, 16 小时失败: 同一份代码、同一个测试, 结果取决于你在几点跑

这里为了统计,我用 patch 把时间依次设到 24 个整点。结论是:一个直接断言 is_business_hours() 为 True 的测试,只在 9 到 16 点之间通过,其余 16 个小时都会失败——CI 半夜跑就红了。

验证二:patch 打在哪里

实测 · 本机执行输出
patch('time.time') 之后: shop.is_business_hours() 看到的 time.time() = 1234.0 (生效, 因为它写的是 time.time())
  但 from time import time as time_fn 的那份: 1790690773.009121   ← 没有被替换, 仍是真实时间戳(≈ 现在)
改成 patch('shop.time_fn') 之后: 1234.0 (patch 要打在「使用它的地方」)
patch('datetime.datetime.now') → TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'

这是最常见的 patch 陷阱:patch("time.time") 替换的是 time 模块里的名字,代码里写 time.time() 的地方会生效,但如果是 from time import time as time_fn 那样把名字拿到自己模块里,拿走的那份引用不会变,得 patch("shop.time_fn")——要 patch「被使用的地方」,而不是「被定义的地方」。另外 datetime.datetime.now 是内置类型上的属性,不能直接替换,会得到 TypeError(常见做法是 patch 整个 datetime 名字,或者用专门的库)。

验证三:更好的办法——把时钟传进去

clock.py def is_business_hours(now=None): now = now or datetime.datetime.now() return 9 <= now.hour < 17
实测 · 本机执行输出
is_business_hours(datetime(2026,9,29,10,0)) = True
  is_business_hours(datetime(2026,9,29,23,0)) = False
  (不需要任何 patch, 也不依赖真实时钟)

如果时间是函数的参数,测试就直接传一个固定时间——不需要 patch、不依赖真实时钟、不会有「打在哪里」的困扰。生产代码不传参数时走默认。这种「依赖注入」比 patch 更直接,代价是需要改接口。

验证四:随机数——种子与独立实例

实测 · 本机执行输出
全局 random 无种子,连跑 8 次 roll(): [4, 1, 3, 4, 1, 4, 2, 5]
  random.seed(42) 两次: [6, 1, 1, 6, 3, 2] [6, 1, 1, 6, 3, 2] 相同: True
  注入独立的 Random(7) 实例: [3, 2, 4, 6, 1, 1] [3, 2, 4, 6, 1, 1]
  全局 seed(1) 与 Random(1) 产生同一个序列: True
  seed(99) 后另造并使用一个 Random(5), 全局序列是否完全不受影响? True
  (seed(0) 之后依次得到 50, 98;test_a 断言第一个值,test_b 没设种子却断言第二个值)
  两个测试共用全局随机状态: 顺序 a → b: 2 passed in 0.07s
  顺序 b → a:                             1 failed, 1 passed in 0.08s

random.seed(42) 两次能复现同一串数;更稳妥的是给函数传一个独立的 random.Random(种子) 实例,它和全局状态互不干扰。全局状态的坏处在最后两行:test_a 设了种子并断言第一个值,test_b 没设种子,「碰巧」断言了第二个值。按 a→b 运行,两个都通过;按 b→a 运行,b 失败——它的结果取决于测试的执行顺序。这类 bug 在开启随机测试顺序或并行运行时才会暴露。

验证五:禁止真实网络

conftest.py(节选) @pytest.fixture def no_network(monkeypatch): real = socket.socket.connect def guard(self, addr, *a, **k): host = addr[0] if isinstance(addr, tuple) else addr if host not in ("127.0.0.1", "::1", "localhost"): raise RuntimeError(f"测试里不允许访问外部网络: {addr}") return real(self, addr, *a, **k) monkeypatch.setattr(socket.socket, "connect", guard)
实测 · 本机执行输出
  [无保护] TimeoutError 用时 0.60 s
  [有保护] RuntimeError: 测试里不允许访问外部网络: ('203.0.113.1', 80) 用时 0.0003 s
  [有保护] 连接本机回环地址仍然可以
   3 passed in 0.71s

用一个 fixture 替换 socket.connect:除回环地址外一律抛异常。实验里连的是 203.0.113.1(文档保留地址,不会有真实主机应答,不会对任何人发流量):没有保护时要等 0.60 s 才超时;有保护时 0.3 毫秒就报错,而且报错信息直接说明「测试里试图访问外网」——比一个莫名其妙的超时好排查得多,也让测试不再因为网络抖动而随机失败。

验证六:别真的 sleep

实测 · 本机执行输出
  test_real_sleep      → 1 passed in 1.12s
  test_patched_sleep   → 1 passed in 0.07s

带重试的函数每次失败后 time.sleep(delay)。把 time.sleep 换成「往列表里记一下」的函数,测试从 1.12 s 降到 0.07 s,而且可以顺便断言「一共等了 [0.5, 0.5]」——既快又验证了重试的间隔逻辑。

分步演示:让时间、随机、网络、sleep 都变得可控

所有数字取自上面的真实实验
测试的不确定性
当前主题

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,pytest 9.1.1(临时虚拟环境),标准库 unittest.mock;网络实验只访问文档保留地址 203.0.113.1(不会有应答)和本机回环
    • 没有验证:freezegun / time-machine / pytest-socket 等专门的库、asyncio 的事件循环时钟(loop.time)、时区与夏令时相关的问题
    京ICP备2026067502号-1京公网安备11010502063487号