上一篇:10 运维中心与清理加速 | 目录:README | 下一篇:12 部署上线与系列总结
到上一篇为止,功能全部写完。但"能跑"和"敢改"是两回事——没有测试的代码库, 每次改动都靠胆量。这一篇讲项目怎么用 125 个测试用例 + ruff + GitHub Actions 把"敢改"变成现实。
先给全景,再挑最有教学价值的五组测试精讲。
一、测试全景:按"风险"分层,不按代码行数凑数
8 个 app 的测试文件,45 个测试类,全绿时间 < 1 分钟(SQLite 内存库,无外部依赖):
| App | 测试类 | 保护的是什么 |
|---|---|---|
| monitor | TraceContextTests | W3C traceparent 合法解析 + 8 类非法输入拒绝 |
| monitor | SqlSanitizeTests | SQL 敏感字面量脱敏 + 普通列名不误伤 |
| monitor | IngestAuthTests | 令牌三种传递方式、坏令牌 401、读类 API 越权拦截 |
| monitor | MetricsIngestValidationTests | NaN/Infinity/超界丢弃 |
| monitor | MiddlewarePersistenceTests | 中间件真的落库(链路/脱敏/错误标记) |
| monitor | MetricBufferTests / ThreadTests | 入队不入库、flush 落库、批量无丢行、丢弃计数、同步退化 |
| monitor | LeadershipTests / LeaseLoopTests | 租约独占/续约/接管/认输、任务异常不丢循环 |
| monitor | RouteSmokeTests / RateLimitTests | 路由冒烟、限速 429 |
| alerts | EngineBaseTests | 触发与去重、恢复、静默 |
| alerts | ForDurationTests | 毛刺不触发、持续越限才触发、观察期重置 |
| alerts | HysteresisTests | 阈值间抖动不反复、恢复迟滞 |
| alerts | MergeDuplicateTests | 重复事件合并自愈 |
| analytics | AnomalyDetectionTests 等 | 3σ(含零方差边界)、预测、皮尔逊、日志聚类 |
| ops | SsrfGuardTests | 协议/元数据地址/保留段/环回开关/IPv6/禁重定向 |
| ops | HealCommandWhitelistTests / TmpScope / Cooldown | 白名单禁用、拒绝 .bat、参数不进 shell、30s 超时、目录越界 |
| ops | NotifyCryptoTests | 授权码密文落库、换密钥优雅降级 |
| cleaner | BoundedWalkTests / CleanSystemTempTests 等 | 遍历限时限量、Junction 剪枝、"删除越界"逃逸用例 |
| loghub | HandlerTraceCorrelationTests | 请求上下文日志自动携带 trace_id |
分层的逻辑:按"坏了会怎么样"分配测试力度——
- 安全边界(鉴权/SSRF/白名单/删除范围):风险最高,用例最密,全部走"攻击者 视角"写(试着越权、试着逃逸、试着注入,断言"进不去");
- 并发与状态机(租约/缓冲/告警防抖):出错最难排查,逐个状态转换写 (续约不换任期、过期可接管、观察期重置……);
- 算法(3σ/回归/皮尔逊):数学简单但边界多,专门测边界(零方差、样本不足);
- 页面路由:一层薄冒烟(URL 可达、200、关键元素在),保证重构不翻车。
对小白的翻译:不要追求"覆盖率 90%"这种数字,先问"这段代码坏了最疼的是哪里", 把疼的优先钉上测试。
二、精讲 1:W3C Trace Context 的"8 类非法输入"
monitor/tests.py(与仓库一致,这是"边界清单法"的范本——把规范里每一条
"必须拒绝"翻译成用例):
class TraceContextTests(TestCase):
def test_parse_valid_traceparent(self):
ctx = tracing.parse_traceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01')
self.assertEqual(ctx['trace_id'], '4bf92f3577b34da6a3ce929d0e0e4736')
self.assertEqual(ctx['span_id'], '00f067aa0ba902b7')
self.assertEqual(ctx['flags'], '01')
def test_parse_rejects_invalid(self):
bad = [
'',
'not-a-traceparent',
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7', # 缺 flags
'00-00000000000000000000000000000000-00f067aa0ba902b7-01', # 全零 trace-id
'00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01', # 全零 span-id
'ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', # version ff
'00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01', # 大写非法
'00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-0g', # flags 非 hex
]
for value in bad:
self.assertIsNone(tracing.parse_traceparent(value), value)
注意测试风格:合法输入一个、非法输入一批——非法清单直接对照 W3C 规范 §3.2/§3.3 抄,规范说拒绝什么就测什么。写这类测试不需要灵感,需要"照规范逐条核对" 的耐心。
三、精讲 2:告警防抖与迟滞(状态机的时序测试)
alerts/tests.py 的 ForDurationTests(节选,完整对照仓库):
class ForDurationTests(EngineBaseTests):
def _policy(self, **kw):
defaults = dict(name='p', metric_key='test.metric', operator='>',
threshold=10, level='P2', for_minutes=2)
defaults.update(kw)
return AlertPolicy.objects.create(**defaults)
def _metric(self, value):
# 测试用注册表桩:把 metric_value 指到可控数据
CustomMetric.objects.create(name='metric', value=value, created_at=timezone.now())
def test_spike_does_not_fire(self):
"""单次毛刺:不触发"""
policy = self._policy(for_minutes=2)
self._metric(99)
fired, _ = engine.evaluate_once()
self.assertEqual((fired, bool(AlertEvent.objects.count())), (0, False))
# 毛刺过去(观察期内条件解除)
self._metric(1)
engine.evaluate_once()
policy.refresh_from_db()
self.assertIsNone(policy.breach_since) # 计时被重置
self.assertEqual(AlertEvent.objects.count(), 0)
def test_sustained_breach_fires(self):
"""持续越限满 for_minutes:触发"""
policy = self._policy(for_minutes=2)
self._metric(99)
engine.evaluate_once() # 第 1 轮:记时
self.assertIsNone(AlertEvent.objects.first())
AlertPolicy.objects.filter(pk=policy.pk).update(
breach_since=timezone.now() - timedelta(minutes=3)) # 时间快进 3 分钟
engine.evaluate_once() # 第 2 轮:观察期满,触发
fired = AlertEvent.objects.get()
self.assertEqual(fired.status, 'firing')
看点:真实测试不能等 2 分钟——用"直接把 breach_since 改到 3 分钟前"
模拟时间流逝。这是测试时间逻辑的标准技巧(time.monotonic 场景用注入假时钟,
Django 字段场景用 update 直改数据库)。
四、精讲 3:租约选主(并发正确性)
monitor/tests.py 的 LeadershipTests(节选;用 TransactionTestCase
隔离事务,holder_id 注入模拟多进程):
class LeadershipTests(TransactionTestCase):
def test_acquire_exclusive(self):
ok1, _ = leadership.acquire('t1', holder_id='A')
ok2, _ = leadership.acquire('t1', holder_id='B')
self.assertTrue(ok1)
self.assertFalse(ok2) # B 未过期抢不走
def test_expired_taken_over_with_term_bump(self):
leadership.acquire('t2', holder_id='A', ttl=1)
TaskLease.objects.filter(name='t2').update(
expires_at=timezone.now() - timedelta(seconds=1)) # 租约过期
ok, term = leadership.acquire('t2', holder_id='B')
self.assertTrue(ok)
self.assertEqual(term, 2) # 接管,任期 +1
def test_previous_holder_must_yield(self):
ok, _ = leadership.acquire('t3', holder_id='A', ttl=1)
self.assertTrue(ok)
TaskLease.objects.filter(name='t3').update(
expires_at=timezone.now() - timedelta(seconds=1))
ok2, _ = leadership.acquire('t3', holder_id='B') # B 接管
self.assertTrue(ok2)
ok3, _ = leadership.acquire('t3', holder_id='A') # A 必须认输
self.assertFalse(ok3)
看点:多进程并发没起真进程——holder_id 参数把"谁在抢"变成测试可注入的
字符串(acquire() 的 holder_id=None 默认取本进程身份,正是为测试留的缝)。
"被接管的原持有者必须认输"是最容易被漏写的用例——它恰恰是 CAS 正确性的核心。
五、精讲 4:SSRF 闸门(攻击者视角)
ops/tests.py 的 SsrfGuardTests(节选):
class SsrfGuardTests(TestCase):
def test_blocks_metadata_and_reserved(self):
for url in [
'http://169.254.169.254/latest/meta-data/',
'http://0.0.0.0/',
'http://224.0.0.1/',
]:
err = urlsafe.validate_url(url)
self.assertIsNotNone(err, url)
def test_blocks_non_http_schemes(self):
for url in ['file:///etc/passwd', 'ftp://example.com', 'gopher://x']:
self.assertIsNotNone(urlsafe.validate_url(url))
def test_allows_loopback_by_default_and_blocks_when_disabled(self):
self.assertIsNone(urlsafe.validate_url('http://127.0.0.1:8014/'))
with override_settings(OBS_ALLOW_LOOPBACK_URL=False):
# urlsafe 模块级开关读环境变量——测试通过 reload/monkeypatch 验证
...
(仓库版对环境开关用 importlib reload + 环境变量 monkeypatch,完整对照仓库。)
看点:测试清单就是"攻击手法清单"——云元数据、全零地址、组播、非 http 协议、 DNS rebinding 边界……安全测试的第一问永远是"攻击者会怎么用这个功能", 而不是"正常用户会怎么用"。
六、精讲 5:删除边界(cleaner 的"逃逸测试")
cleaner/tests.py 的思路(253 行全在防守删除边界):
class CleanSystemTempTests(TestCase):
def test_rejects_outside_whitelist_root(self):
"""白名单根之外的目录必须被拒绝——哪怕请求里长得像系统临时目录"""
...
def test_junction_symlink_pruned(self):
"""临时目录里塞一个指向外部的 Junction/符号链接:遍历必须剪枝,不进入"""
# Windows 上构造 Junction 指向 C:\Users(或其他真实目录),
# 断言清理后该目标目录原封不动
...
def test_unknown_item_rejected(self):
"""未知清理项一律拒绝(防止配置层新增"顺手"项绕过白名单)"""
...
看点:"逃逸测试"的思维是构造攻击现场——真的建一个 Junction 指向 受保护目录,然后断言删除没打穿。与其争论"应该安全",不如构造一次"尝试不安全"。
七、ruff:静态检查配置
ruff.toml(与仓库一致):
# ruff:CI 与本地共用的静态检查配置(与现有代码风格对齐,宽松起步)
target-version = "py310"
line-length = 110
exclude = ["migrations", "static", "venv", ".venv", "展示图片"]
[lint]
# E/W 基础 pycodestyle + F 逻辑错误;B 常见陷阱;UP 过时写法
select = ["E", "F", "W", "B", "UP"]
ignore = [
"E501", # 行宽交给 line-length 之上的例外(中文注释常超)
]
[lint.per-file-ignores]
"**/tests.py" = ["B011"] # 测试里 assert False 类模式不受限
[format]
quote-style = "single"
选择逻辑:E/F/W 是基本盘(语法错误、未用变量、逻辑错误);B 抓常见陷阱 (可变默认参数、在 for 里改列表);UP 抓过时写法(新版本 Python 有更好写法)。 E501(行宽)放行——中文注释常常超宽,硬卡行宽会逼人把注释写差。
本地跑:
pip install ruff
ruff check .
八、GitHub Actions:四道闸门
.github/workflows/ci.yml(与仓库一致):
# CI:静态检查 + 全量测试(SQLite 内存库,无需外部服务)
name: ci
on:
push:
branches: [main, master]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- name: Django system check
run: python manage.py check
- name: Migration completeness
run: python manage.py makemigrations --check --dry-run
- name: Tests
run: python manage.py test --verbosity 2
env:
OBS_DISABLE_WORKERS: "1"
四道闸门各自的意义:
- ruff:低级错误进不了 main;
- manage.py check:配置错误(中间件/INSTALLED_APPS 拼错)秒级发现;
makemigrations --check --dry-run:你改了 models 却忘了生成迁移—— 这是 Django 项目最经典的"本地好好的,部署就炸",这道闸门专门抓它;- 全量测试(
OBS_DISABLE_WORKERS=1关后台线程,避免 CI 里线程与测试互抢)。
推上 GitHub 后每次 push/PR 自动跑。徽章(README 顶部那排)来自 workflow 的 URL。
九、写你自己的第一批测试
不必一口气补 125 个——按风险排序,先给项目钉上这 8 个(半小时的事):
# monitor/tests.py 新手起手式
from django.test import TestCase
from monitor import tracing
from monitor.middleware import _sanitize_sql
class TracingSmokeTest(TestCase):
def test_w3c_shape(self):
tid = tracing.new_trace_id()
self.assertEqual(len(tid), 32)
self.assertTrue(set(tid) <= set('0123456789abcdef'))
def test_rejects_garbage(self):
self.assertIsNone(tracing.parse_traceparent(''))
self.assertIsNone(tracing.parse_traceparent('xx' * 28))
class SanitizeSmokeTest(TestCase):
def test_secret_masked(self):
# 测试值是无意义占位,不是真实凭据
sql = "SELECT * FROM t WHERE api_key = 'demo-123'"
self.assertIn('***', _sanitize_sql(sql))
def test_normal_column_untouched(self):
sql = "SELECT * FROM t WHERE author_id = 7"
self.assertEqual(_sanitize_sql(sql), sql)
跑法:
python manage.py test # 全部
python manage.py test monitor # 单 app
python manage.py test monitor.tests.SanitizeSmokeTest # 单类
本篇检查点
- [ ]
python manage.py test全绿(你已写的测试); - [ ]
ruff check .通过; - [ ] CI 在 GitHub 上跑绿(如已推送);
- [ ] 故意改坏一处(把
parse_traceparent的大写校验去掉)→ 测试变红 → 改回 → 变绿; 亲手体验一次"测试在保护你"; - [ ]
makemigrations --check通过(改 models 后记得迁移)。
常见报错速查
| 报错 | 原因 | 解法 |
|---|---|---|
| 测试里后台线程抢库 | 缓冲线程在测试进程活着 | settings 已按命令名关闭(03 篇);确认 WORKER_START_EXCLUDE_CMDS 生效 |
test 时 migrate 报错 |
测试库不含某迁移 | makemigrations --check 先过一遍 |
| CI 缓存失效 | 依赖文件变了 | 正常,重新安装即可 |
| TransactionTestCase 数据残留 | 它不包事务 | 每个用例自己清理(对照仓库 LeadershipTests 的 tearDown) |
质量闸门就位。最后一篇:从一键脚本到 Docker Compose 三角色编排到 systemd + nginx/HTTPS——把平台送上公网,然后回顾整个系列—— 12 · 部署上线与系列总结。