mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-30 03:48:03 +08:00
192 lines
15 KiB
Markdown
192 lines
15 KiB
Markdown
# 单元测试规范
|
||
|
||
本文档定义 MoviePilot 后端(`app/`)单元测试的统一约定:运行入口、隔离模型、编写规范、`unittest → pytest` 演进路线,以及排查测试问题的常用手段。目标是让 `tests/` 在 **CI / 全新环境**下可**离线、可重复、零外部依赖**地跑完。
|
||
|
||
## 运行入口:统一 pytest
|
||
|
||
pytest 是唯一运行入口。`tests/conftest.py` 在收集前完成隔离引导,因此任何方式启动 pytest 都会自动隔离。
|
||
|
||
```bash
|
||
uv run --locked --no-sync pytest tests # 串行全量
|
||
uv run --locked --no-sync pytest tests/test_xxx.py # 单文件
|
||
uv run --locked --no-sync pytest tests/test_xxx.py::SomeTest::test_y # 单用例
|
||
uv run --locked --no-sync python tests/run.py # 默认按文件预计耗时均衡为 4 片并行跑全量
|
||
uv run --locked --no-sync python tests/run.py --serial # 串行全量,便于调试或生成覆盖率
|
||
uv run --locked --no-sync python tests/run.py --shard 1/4 # 只跑指定分片,供 CI 复用
|
||
```
|
||
|
||
`tests/run.py` 的 runner 参数只有 `--serial` 和 `--shard N/TOTAL`;其余参数保持原顺序
|
||
透传给 pytest,例如 `python tests/run.py -q --maxfail=1`。分片使用受版本控制的
|
||
`tests/fixtures/durations.json` 慢文件耗时估算:按耗时降序,逐个分给预计总耗时最短的分片;
|
||
耗时相同时按路径、分片编号决定归属,片内仍按路径排序。新增或未记录文件使用 1 秒权重,
|
||
不依赖本机缓存,确保同一代码版本在本地与 CI 的文件集合和顺序一致。
|
||
耗时数据来自成功 Ubuntu/Python 3.14 Coverage 日志的逐文件进度时间戳(含续行),
|
||
只记录至少 3 秒的文件并向上取整;后续发现慢文件偏移时可依据成功 CI 日志更新。
|
||
这些估算仅用于调度,不构成性能或覆盖率基线。
|
||
|
||
- 不再使用 `python -m unittest discover`:它不导入 `tests` 包、收不到纯函数用例,且绕过 `conftest.py` 的隔离。
|
||
- 不再依赖 `python tests/test_xxx.py` 直跑:所有 `if __name__ == "__main__": unittest.main()` 尾巴已移除。
|
||
- **复现 CI 用干净环境**:使用 `uv sync --locked` 从 `uv.lock` 创建环境,再以
|
||
`uv run --locked --no-sync` 运行测试,避免本地额外包、未锁定解析结果或编译产物掩盖问题。
|
||
|
||
共享工作区的 `.venv-test` 与上述 `uv` 命令映射见 [开发环境设置](development-setup.md);
|
||
`--no-sync` 不会验证已安装依赖是否匹配锁文件。
|
||
|
||
## 验证范围与维护者安排
|
||
|
||
Contributor 默认在提交前运行受影响测试与适用本地检查。依赖或锁文件、共享测试脚手架、数据库、
|
||
启动路径、跨模块生命周期、兼容层、大范围行为改动或维护者明确要求时,运行完整
|
||
`uv run --locked --no-sync python tests/run.py`。纯文档及其契约测试采用文本、结构、链接和
|
||
focused 测试验证,不要求启动产品全套测试。
|
||
|
||
已确认的维护者可按 `AGENTS.md` 明确调整验证范围、时机及交付顺序,复用仍有效的源码、锁文件、
|
||
脚手架和环境证据,也可先保存本地 anchor 再补检查。记录依据、未验证项和后续安排;HEAD 变化
|
||
本身不要求本地全量重跑,但实际变化使哪些证据失效,就重跑哪些检查。这不改变 GitHub Actions
|
||
按精确 merge tree/base 复用的实现合同,也不取消测试隔离或下述 TestCase 整文件迁移要求。
|
||
|
||
改动涉及的行为必须有可信验证。失败须判断是否由本次改动造成、加重或重新触达;无关失败用当前
|
||
目标 base 复现或其他充分证据说明,并按 `docs/rules/12-collaboration-and-distribution.md`
|
||
的已记录维护者决定处置。已预授权的同一范围无需逐次重问,缺少证据时保留未知,不伪报通过。
|
||
|
||
## 隔离模型(`tests/conftest.py`)
|
||
|
||
收集任何测试模块、`import app.*` **之前**,conftest 完成两件事:
|
||
|
||
1. **临时库**:把 `CONFIG_DIR` 指向临时目录并 `init_db()` 建表。引擎本身已惰性创建(`import app.db` 不再连库),但 `settings` 在 `import app.runtime.config` 那一刻就把 `CONFIG_DIR` 读进字段并建好配置子目录,之后再改环境变量对 `settings.CONFIG_PATH` 毫无影响——引擎晚点才建,连的仍是真实 `user.db`。所以隔离必须早于首个牵入 `app.runtime.config` 的 import(`app.db` / `app.chain.*` 都会牵入);空库会让运行期查表报 `no such table`,故必须建表。
|
||
2. **`app.application.site.sites` 垫片**:该模块由独立资源仓按平台下发,conftest 统一安装最小垫片,普通单测不会加载源码目录中的 `.so` / `.pyd`。兼容层会把旧插件的 `app.helper.sites` 导入路由到同一模块;真实制品由资源与 ABI 专项验收覆盖。
|
||
|
||
由此推出两条**硬规范**:
|
||
|
||
- 用例**不得**连接或写入真实数据库、不得读写真实 `config/`。需要的库状态在用例内构造。
|
||
- 用例**不得**依赖某个本地才有的动态模块副本;缺失的外部模块由 conftest 兜底或用例自行 mock。
|
||
|
||
## 外部依赖:一律 mock,零真实网络
|
||
|
||
测试**禁止**发起任何真实外部请求,包括但不限于 TMDB(`api.themoviedb.org`)、LLM 目录(`models.dev`)、下载器、媒体服务器、MP 服务器(`movie-pilot.org` 的共享识别 API)、以及任意外链图片/资源。**验收标准是全量跑测零真实出站**。
|
||
|
||
两种标准做法:
|
||
|
||
**1. 在调用边界打桩**(外部客户端、helper、SDK 入口):
|
||
|
||
```python
|
||
from unittest.mock import patch, AsyncMock
|
||
|
||
with patch.object(SomeModule, "fetch", new=AsyncMock(return_value=FAKE)):
|
||
...
|
||
```
|
||
|
||
**2. 外部 HTTP API 用「录制—回放」(cassette)**:一次性录制真实响应存入 `tests/fixtures/`,测试时按请求键回放,使识别/解析等逻辑仍由真实结构数据驱动,但全程离线。参考实现:`tests/test_tmdb_recognize.py` + `tests/fixtures/tmdb_recognize_cassette.json`(在 `setUpModule` 中替换 TMDB 客户端的 HTTP 出入口;重新录制时临时包裹该出入口、跑一遍真实请求并落盘)。
|
||
|
||
> 注意:识别这类端到端流程往往不止一个外部出口。例如 TMDB 识别除了目录请求,链路层还会向 MP 服务器上报/查询「共享识别 API」——这类旁路出口必须一并打桩。用下文的 socket 探针确认确实零出站。
|
||
|
||
## 自隔离:用了什么,就还原什么
|
||
|
||
用例若修改了**进程级状态**——`sys.modules` 桩、单例(`Singleton._instances`)、`lru_cache`、环境变量、`settings` 字段——必须在用例或模块结束时还原。pytest 一次性导入全部测试模块,未还原的污染会扩散到后续用例,产生“单独跑过、一起跑挂”的测不准现象。
|
||
|
||
正确姿势:
|
||
|
||
- 上下文管理器(`with patch(...)`)、`setUp` + `addCleanup`、或方法内 `patch`,退出即还原。
|
||
- 模块级需要的桩用上下文包住 import 段,import 完即还原。
|
||
|
||
反模式(**评审应拒绝**):
|
||
|
||
- 模块顶层 `sys.modules["x"] = stub` 且不还原。
|
||
- 桩掉 `requirements` 里**真实可用**的第三方包(如把 `cn2an.an2cn` 换成 `str`),导致被测行为漂移;真包能用就用真包。
|
||
- 依赖测试执行顺序。
|
||
|
||
## 编写新测试:强制 pytest 原生
|
||
|
||
新增测试**一律** pytest 原生风格,评审不接受新写的 `unittest.TestCase`:
|
||
|
||
- 文件名 `test_*.py`,置于 `tests/`。
|
||
- 函数式用例 `def test_xxx():` + 普通 `assert` + pytest fixture,不用 `self.assertXxx`。
|
||
- 涉及外部服务一律 mock(见上)。
|
||
- 异常断言用 `pytest.raises`,参数化用 `@pytest.mark.parametrize`。
|
||
|
||
```python
|
||
import pytest
|
||
|
||
from app.schemas.types import MediaSource
|
||
|
||
@pytest.fixture
|
||
def sample_meta():
|
||
"""构造一条可复用的识别元数据。"""
|
||
return MetaInfo(title="示例 (2020)")
|
||
|
||
def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
|
||
"""显式媒体来源与原生 ID 时应优先精确识别,而非回退标题搜索。"""
|
||
monkeypatch.setattr(SomeClient, "fetch", lambda *a, **k: FAKE_MOVIE)
|
||
result = recognize(
|
||
sample_meta,
|
||
media_source=MediaSource.TMDB,
|
||
media_id="123",
|
||
)
|
||
assert result.media_source == MediaSource.TMDB
|
||
assert result.media_id == "123"
|
||
```
|
||
|
||
## `unittest → pytest` 演进路线:改到即转
|
||
|
||
存量有大量 `unittest.TestCase`。pytest 原生支持运行 `TestCase`,所以它们能正常跑——**不做大爆炸式重写**,避免无谓的回归风险。路线是:
|
||
|
||
- **新测试**:直接 pytest 原生(见上)。
|
||
- **存量**:当你因别的原因改到某个 `TestCase` 文件时,**顺手**把它整文件转成 pytest 原生,并跑一遍该文件确认行为不变。
|
||
- 不为转换而转换:没有改动需求的文件可暂时保留 `TestCase`。
|
||
|
||
常见转换对照:
|
||
|
||
| unittest | pytest 原生 |
|
||
| --- | --- |
|
||
| `class T(unittest.TestCase):` + 方法 | 模块级 `def test_xxx():` |
|
||
| `self.assertEqual(a, b)` | `assert a == b` |
|
||
| `self.assertTrue(x)` / `assertFalse(x)` | `assert x` / `assert not x` |
|
||
| `self.assertIn(a, b)` / `assertNotIn` | `assert a in b` / `assert a not in b` |
|
||
| `self.assertIsNone(x)` / `assertIsNotNone` | `assert x is None` / `assert x is not None` |
|
||
| `self.assertRaises(E)` | `with pytest.raises(E):` |
|
||
| `setUp` / `tearDown` | fixture(`yield` 前为准备、后为清理)|
|
||
| `setUpClass` / `tearDownClass` | `@pytest.fixture(scope="class")` 或模块级 fixture |
|
||
| `@unittest.skipIf(c, r)` | `@pytest.mark.skipif(c, reason=r)` |
|
||
|
||
## 存量测试清理
|
||
|
||
删除测试需要明确指出保留的等价覆盖,不能只依据文件年代、执行耗时、名称相似或覆盖率百分比。
|
||
|
||
- **可删除的重复**:被测入口、输入、fixture、参数化和执行前状态一致,且保留用例包含全部断言。完全相同的执行体可保留一份;同输入的断言子集可并入更完整用例的说明。
|
||
- **不能自动删除的相似用例**:同步与异步入口、冷启动与重复重置、过期清理前后、迁移前后、不同平台或可选依赖条件,均可能拥有独立行为。
|
||
- **没有直接 `assert` 不等于空测试**:`pytest.raises`、辅助断言、迁移 round-trip、导入兼容和“不抛异常”都是有效契约;只有名称或注释声称存在的覆盖则需要进一步核实。
|
||
- **删除后验证**:运行受影响文件,核对保留用例及生产代码行/分支覆盖;跨领域清理再运行 `python tests/run.py`。不降低覆盖率基线、不新增 skip、不削弱架构或兼容门禁。
|
||
|
||
首轮扫描范围、删除映射和保留理由见 [后端测试清理审计](testing-cleanup.md)。
|
||
|
||
## 排查测试问题
|
||
|
||
- **收集报错(collection error)**:多为 import 期副作用或顶层桩污染。优先改成真实 import(conftest 已隔离临时库,真实 `settings`/helper 可加载)+ 方法内 patch,而不是靠事后还原(收集期污染发生在 import 那一刻,事后还原太晚)。
|
||
- **检测真实网络泄漏**:进程级挂一个 `socket.getaddrinfo` 探针记录非本地出站主机,跑目标用例即可定位是谁在联网:
|
||
|
||
```python
|
||
import socket
|
||
_orig = socket.getaddrinfo
|
||
hits = []
|
||
def _spy(host, *a, **k):
|
||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||
hits.append(str(host))
|
||
return _orig(host, *a, **k)
|
||
socket.getaddrinfo = _spy
|
||
# 跑用例后断言 hits 为空
|
||
```
|
||
|
||
- **测试间污染(测不准)**:定位被改而未还原的进程级状态(单例 / `lru_cache` / `sys.modules` / 环境变量 / `settings`),按「自隔离」补还原。
|
||
- **怀疑用例空过**:用变异验证——临时打断对应生产逻辑(让它返回错误值),跑该用例应**失败**;若仍通过,说明断言没真正覆盖该逻辑。
|
||
|
||
## CI 与 PR
|
||
|
||
- **合并检查复用**:单测/架构与 Pylint 工作流各自保留 PR、push 和手动入口。PR 完整通过全部门禁后,末尾 `CI proof (<github.sha>)` job 记录实际验证的模拟合并提交。合并 push 只在同一工作流的最新 PR 运行完整成功、证明成功、代码树完全相同、模拟合并父提交分别等于 push 前的目标分支和 PR head 时跳过重复门禁。直接 push、强制 push、基线变化、旧工作流缺少证明、失败/未完成运行或 API 异常都执行全量;不依赖提交消息,也不把 PR 的 head SHA 当作测试的合并 SHA。标准 merge/squash/rebase 仅在上述证据一致时复用。GitHub 构建与发布工作流保持独立。
|
||
- **去重脚本验证**:`node --test .github/scripts/reuse.test.mjs` 离线覆盖成功复用及保守回退,两个检查工作流均在判定前执行。复用 job 仅持有 contents/actions/pull-requests 读取权限;没有修改分支保护设置。
|
||
|
||
- **门禁**:`.github/workflows/test.yml` 在指向 `v3` 的 `pull_request` / `push` 及手动触发时,从 `uv.lock` 同步环境。独立 `architecture` job 先运行宿主依赖、运行契约和基线 CLI 快速门禁;全量测试通过 `coverage run --parallel-mode tests/run.py --shard N/8` 分到 8 个 job,一次执行同时验证单测并采集覆盖率。每个分片都有独立进程和临时 `CONFIG_DIR`,不共用 SQLite 或进程级状态,由单一报告 job 合并后检查 Application 与 Domain 的固定 80% 基线。
|
||
- **跨仓观察**:`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。
|
||
- **静态检查**:`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。
|
||
- **PR 本地验证**:按上文「验证范围与维护者安排」选择 contributor 默认检查或已记录的维护者安排,统一处理证据复用与失败归属;需要断点、输出顺序或测试污染诊断时使用 `--serial`。确认受影响路径与零真实出站,准确标注验证范围;本地执行安排不改变 CI 的全量验证与复用合同。
|
||
- **覆盖率门禁**:`Unit Tests with Coverage` jobs 会在 `v3` 的 PR、push 和手工触发中通过 `tests/run.py --shard N/8` 并行采集覆盖率数据,`Coverage Report` 再合并全部分片并只读检查 Application 与 Domain 是否达到 Ubuntu/Python 3.14 canonical 的固定 80% 行覆盖率基线,同时上传 JSON / XML 工件。低于 80% 会阻塞;达到或超过 80% 不要求同步运行时语句计数。macOS 本地报告只用于诊断,不直接作为可提交基线。
|
||
- 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`,pytest 与覆盖率工具位于默认 `dev` 依赖组。
|