运维脚本常被当成「跑一次就扔」的一次性代码,但现实恰恰相反:巡检、清理、守护脚本一跑就是几年,改一次阈值、换一个路径,都可能悄悄改坏一个你从没测过的分支。这篇给系列的监控脚本(01 篇的 monitor.py)补上测试防线,顺带演示完整的 TDD 流程——测试先写、先跑、先看它失败。
一、运维脚本测什么:先切出纯函数
monitor.py 大部分代码依赖真实世界:psutil 读的是当下这台机器,requests 发的是真网络。这类代码没法直接断言(你不可能要求测试时 CPU 恰好 80%)。pytest 世界的正确切入点是把「可判定的逻辑」抽成纯函数:
def format_bytes(num_bytes: float) -> str:
"""把字节数转成人类可读字符串:1024 -> '1.0 KB'。"""
units = ["B", "KB", "MB", "GB", "TB"]
value = float(num_bytes)
for unit in units:
if value < 1024 or unit == units[-1]:
return f"{value:.1f} {unit}"
value /= 1024
raise AssertionError("unreachable")
def should_alert(value: float, threshold: float) -> bool:
"""指标值达到阈值即告警(>= 而非 >:监控里'等于阈值'通常就该报)。"""
return value >= threshold
纯函数「输入定、输出定」,是天生的测试素材。而依赖系统的函数(如 check_memory())用 mock 隔离来测——后面第五节见。
二、先写测试,看它失败(RED)
测试先行不是仪式感:先写测试,能第一时间发现「函数根本不存在」「导入路径不对」这类最基础的问题,也能强迫你在实现之前想清楚接口长什么样。
tests/test_monitor.py 第一版:
import pytest
from monitor import format_bytes, should_alert
class TestFormatBytes:
def test_bytes_to_kb(self):
assert format_bytes(1024) == "1.0 KB"
def test_bytes_to_mb(self):
assert format_bytes(1024 * 1024) == "1.0 MB"
def test_bytes_to_gb(self):
assert format_bytes(1024**3 * 1.5) == "1.5 GB"
def test_zero(self):
assert format_bytes(0) == "0.0 B"
class TestShouldAlert:
def test_over_threshold_triggers(self):
assert should_alert(92.0, 90) is True
def test_equal_threshold_triggers(self):
# 达到阈值也算超——监控里「等于」通常就该报
assert should_alert(90.0, 90) is True
def test_below_threshold_silent(self):
assert should_alert(89.9, 90) is False
跑 pytest,第一层失败如约而至:
ERROR collecting tests/test_monitor.py ____________________
ImportError while importing test module '...\tests\test_monitor.py'.
tests\test_monitor.py:5: in <module>
from monitor import format_bytes, should_alert
E ModuleNotFoundError: No module named 'monitor'
pytest 从 tests/ 目录起跑,找不到项目根目录下的 monitor.py。修法是给项目加一个 pytest.ini:
[pytest]
testpaths = tests
pythonpath = .
pythonpath = . 把项目根加进导入路径。再跑,第二层失败浮出水面——这才是「测试先行」真正期待的那种失败:
ERROR collecting tests/test_monitor.py ____________________
tests\test_monitor.py:5: in <module>
from monitor import format_bytes, should_alert
E ImportError: cannot import name 'format_bytes' from 'monitor' (...)monitor.py
导入路径通了,但函数还没写——红灯亮得清清楚楚。
三、实现,让测试变绿(GREEN)
往 monitor.py 里补上第二节那两个函数,再跑:
============================= test session starts =============================
platform win32 -- Python 3.13.15, pytest-9.1.1, pluggy-1.6.0
configfile: pytest.ini
collected 7 items
tests/test_monitor.py::TestFormatBytes::test_bytes_to_kb PASSED [ 14%]
tests/test_monitor.py::TestFormatBytes::test_bytes_to_mb PASSED [ 28%]
tests/test_monitor.py::TestFormatBytes::test_bytes_to_gb PASSED [ 42%]
tests/test_monitor.py::TestFormatBytes::test_zero PASSED [ 57%]
tests/test_monitor.py::TestShouldAlert::test_over_threshold_triggers PASSED [ 71%]
tests/test_monitor.py::TestShouldAlert::test_equal_threshold_triggers PASSED [ 85%]
tests/test_monitor.py::TestShouldAlert::test_below_threshold_silent PASSED [100%]
============================== 7 passed in 0.30s ==============================
7 passed,绿灯。整个 RED → GREEN 循环里每一步的失败输出都是真实的——包括那两个看起来很傻的 ModuleNotFoundError 和 ImportError。别小看它们:没有测试防线的项目里,这类「最基础的问题」往往要到运行时才暴露。
四、为什么连 should_alert 这种一行函数都要测
「value >= threshold 一眼就看懂了,测它干嘛?」——因为这个函数吃过教训的大亏:
- 有人手滑写成
>:磁盘正好用到 90.0% 时不告警,而磁盘报警往往恰恰发生在卡线位置; - 有人写成
>=但把参数顺序传反:should_alert(90, value),测试名test_equal_threshold_triggers当场抓住。
测试的另一半价值是把决定固化成文档:「等于阈值算不算超」是产品决策,写成测试用例后,谁想改这个行为,得先过测试这一关。
五、隔离系统依赖:monkeypatch 伪造 psutil
check_memory() 读的是真实内存,怎么测它的两个分支?pytest 的 monkeypatch 夹具可以在测试期间偷换任意对象:
class TestCheckMemoryWithFake:
"""用 monkeypatch 伪造 psutil.virtual_memory,隔离真实系统状态来测分支。"""
class FakeMem:
percent = 91.2
used = 11 * 1024**3
total = 16 * 1024**3
def test_high_memory_returns_alert(self, monkeypatch):
import monitor
monkeypatch.setattr(monitor.psutil, "virtual_memory", lambda: self.FakeMem())
alert = monitor.check_memory()
assert alert is not None
assert "91.2%" in alert
def test_low_memory_stays_silent(self, monkeypatch):
import monitor
mem = self.FakeMem()
mem.percent = 40.0
monkeypatch.setattr(monitor.psutil, "virtual_memory", lambda: mem)
assert monitor.check_memory() is None
monkeypatch.setattr 把 monitor.psutil.virtual_memory 换成了返回假数据的 lambda——测试不再关心你这台机器真实内存多少,91.2% 时必须告警、40.0% 时必须沉默,两个分支在任何机器上都能稳定复现。测试结束 monkeypatch 自动还原,不留副作用。
最终测试全景:
collected 9 items
tests/test_monitor.py::TestFormatBytes::test_bytes_to_kb PASSED [ 11%]
tests/test_monitor.py::TestFormatBytes::test_bytes_to_mb PASSED [ 22%]
tests/test_monitor.py::TestFormatBytes::test_bytes_to_gb PASSED [ 33%]
tests/test_monitor.py::TestFormatBytes::test_zero PASSED [ 44%]
tests/test_monitor.py::TestShouldAlert::test_over_threshold_triggers PASSED [ 55%]
tests/test_monitor.py::TestShouldAlert::test_equal_threshold_triggers PASSED [ 66%]
tests/test_monitor.py::TestShouldAlert::test_below_threshold_silent PASSED [ 77%]
tests/test_monitor.py::TestCheckMemoryWithFake::test_high_memory_returns_alert PASSED [ 88%]
tests/test_monitor.py::TestCheckMemoryWithFake::test_low_memory_stays_silent PASSED [100%]
============================== 9 passed in 0.29s ==============================
六、运维脚本的测试策略:测逻辑,不测世界
把本篇的方法论收拢成三句话:
- 纯逻辑直接测:格式化、阈值判断、路径规则(03 篇的
scan()就可以用os.utime造「90 天前的文件」来测); - 系统依赖 mock 掉测分支:
psutil、requests、时间、随机数,都能 monkeypatch;测的是「给定输入时你的分支走对没有」,不是「psutil 报数准不准」; - 接进交付流程:
pytest挂进 git pre-commit 或 CI,10 篇的打包动作只在绿灯后执行——测试就是发布闸门。
还有一条运维特有的纪律:测试数据也要造假造假得像真的。02 篇日志分析用的仿真日志里故意混了 2% 坏行,03 篇沙箱里故意放了「后缀命中但很新」的文件——边界样本不进测试集,线上迟早替你测一遍。
七、诚实的边界
9 个测试不等于 monitor.py 高枕无忧:notify() 的网络路径、run_once() 的组装逻辑、常驻模式的调度循环,要么没测(需要更重的 mock 布置),要么测了性价比很低。运维脚本测试的目标是用最少的用例锁住最容易改坏的逻辑,追求覆盖率数字是另一回事。先让核心逻辑有防线,防线跟着事故长——这才是可持续的做法。
最后一篇解决「脚本怎么交到别人手里」:打包成单文件。
本篇代码:tests/test_monitor.py、pytest.ini 及 monitor.py 新增的两个纯函数,已在 Python 3.13.15 / Windows(pytest 9.1.1)真实运行;文中全部 pytest 输出(含两次失败)原样收录。