mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-24 07:07:55 +08:00
test: 新增单测 CI 门禁与规范文档,处理 #5868/#5873 review 反馈 (#5877)
This commit is contained in:
48
.github/workflows/test.yml
vendored
Normal file
48
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
# 指向 v2 的 PR 与推送都跑全量单测,作为合并门禁
|
||||
pull_request:
|
||||
branches:
|
||||
- v2
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
# 允许手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
name: Unit Tests
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.in', '**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
# 用 requirements.in 还原 CI / 全新环境(含 pytest~=8.4 与 moviepilot-rust 等可选扩展),
|
||||
# 与本地"干净 venv 复现"一致;测试运行器 pytest 已在 requirements.in 中声明。
|
||||
pip install -r requirements.in
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
# tests/run.py 以 pytest 跑 tests 全量;tests/conftest.py 在收集前把 CONFIG_DIR
|
||||
# 指向临时库并建表,测试杜绝真实网络/外部服务(详见 docs/testing.md)。
|
||||
python tests/run.py
|
||||
@@ -41,6 +41,10 @@ Before executing any task, identify the domain and load the corresponding docume
|
||||
* **Primary Reference:** `docs/rules/11-quality-and-security.md`
|
||||
* **Required Constraints:** All code changes must pass the relevant pytest tests and pylint checks. Dependency changes require a passing safety scan.
|
||||
|
||||
### Testing
|
||||
* **Primary Reference:** `docs/testing.md`
|
||||
* **Required Constraints:** pytest is the only runner; `tests/conftest.py` isolates each run to a temporary `CONFIG_DIR`. Tests must not touch the real database, network, or external services (TMDB, LLM catalogs, downloaders, media servers, MP server) — mock at the boundary or replay recorded responses; the bar is zero real outbound traffic. Tests must restore any process-level state they stub (`sys.modules`, singletons, caches, settings). New tests must be pytest-native (function + `assert` + fixtures); do not add new `unittest.TestCase`. Convert existing `TestCase` files to pytest-native opportunistically when you modify them.
|
||||
|
||||
### Commands and Development Workflow
|
||||
* **Primary Reference:** `docs/rules/03-commands.md`
|
||||
* **Required Constraints:** Only suggest or execute commands documented in that file. Do not assume tool defaults or global flags.
|
||||
|
||||
@@ -50,7 +50,9 @@ def snapshot_modules(prefix: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
if prefix is None:
|
||||
return dict(sys.modules)
|
||||
return {k: v for k, v in sys.modules.items() if k == prefix.rstrip(".") or k.startswith(prefix)}
|
||||
# 归一去掉末尾点后按"精确父模块或其子模块路径"匹配,避免 prefix="app" 误配到 "apple"
|
||||
parent = prefix.rstrip(".")
|
||||
return {k: v for k, v in sys.modules.items() if k == parent or k.startswith(parent + ".")}
|
||||
|
||||
|
||||
def restore_modules(snapshot: Dict[str, Any], prefix: Optional[str] = None) -> None:
|
||||
@@ -65,7 +67,8 @@ def restore_modules(snapshot: Dict[str, Any], prefix: Optional[str] = None) -> N
|
||||
in_scope = lambda name: True # noqa: E731
|
||||
else:
|
||||
head = prefix.rstrip(".")
|
||||
in_scope = lambda name: name == head or name.startswith(prefix) # noqa: E731
|
||||
# 同 snapshot_modules:精确父模块或其子模块路径,避免 prefix="app" 误配 "apple"
|
||||
in_scope = lambda name: name == head or name.startswith(head + ".") # noqa: E731
|
||||
# 移除范围内、快照中没有的新增项(通常是测试塞入的假桩)
|
||||
for name in [n for n in sys.modules if in_scope(n) and n not in snapshot]:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
134
docs/testing.md
Normal file
134
docs/testing.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# 单元测试规范
|
||||
|
||||
本文档定义 MoviePilot 后端(`app/`)单元测试的统一约定:运行入口、隔离模型、编写规范、`unittest → pytest` 演进路线,以及排查测试问题的常用手段。目标是让 `tests/` 在 **CI / 全新环境**下可**离线、可重复、零外部依赖**地跑完。
|
||||
|
||||
## 运行入口:统一 pytest
|
||||
|
||||
pytest 是唯一运行入口。`tests/conftest.py` 在收集前完成隔离引导,因此任何方式启动 pytest 都会自动隔离。
|
||||
|
||||
```bash
|
||||
pytest tests # 全量
|
||||
pytest tests/test_xxx.py # 单文件
|
||||
pytest tests/test_xxx.py::SomeTest::test_y # 单用例
|
||||
python tests/run.py # 等价于 pytest 全量(参数透传)
|
||||
```
|
||||
|
||||
- 不再使用 `python -m unittest discover`:它不导入 `tests` 包、收不到纯函数用例,且绕过 `conftest.py` 的隔离。
|
||||
- 不再依赖 `python tests/test_xxx.py` 直跑:所有 `if __name__ == "__main__": unittest.main()` 尾巴已移除。
|
||||
- **复现 CI 用干净环境**:建议用一个仅 `pip install -r requirements.in pytest` 的虚拟环境运行,避免本地额外包或编译产物掩盖问题。
|
||||
|
||||
## 隔离模型(`tests/conftest.py`)
|
||||
|
||||
收集任何测试模块、`import app.*` **之前**,conftest 完成两件事:
|
||||
|
||||
1. **临时库**:把 `CONFIG_DIR` 指向临时目录并 `init_db()` 建表。`app.db` 在导入期即按 `CONFIG_PATH` 连接 `user.db`,所以必须早于首个 `import app.*`;空库会让运行期查表报 `no such table`,故必须建表。
|
||||
2. **`app.helper.sites` 垫片**:该模块由独立仓库动态拉取、CI 无此文件,conftest 统一补最小垫片(本地存在真实模块时优先用真实模块)。
|
||||
|
||||
由此推出两条**硬规范**:
|
||||
|
||||
- 用例**不得**连接或写入真实数据库、不得读写真实 `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
|
||||
|
||||
@pytest.fixture
|
||||
def sample_meta():
|
||||
"""构造一条可复用的识别元数据。"""
|
||||
return MetaInfo(title="示例 (2020)")
|
||||
|
||||
def test_recognize_prefers_explicit_id(sample_meta, monkeypatch):
|
||||
"""显式 tmdbid 时应优先按 ID 识别,而非回退标题搜索。"""
|
||||
monkeypatch.setattr(SomeClient, "fetch", lambda *a, **k: FAKE_MOVIE)
|
||||
result = recognize(sample_meta, tmdbid=123)
|
||||
assert result.tmdb_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)` |
|
||||
|
||||
## 排查测试问题
|
||||
|
||||
- **收集报错(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
|
||||
|
||||
CI 以 pytest 运行 `tests`。建议 CI 与本地复现都用仅安装 `requirements.in` 的干净环境,保证可选扩展、动态模块的存在性与 CI 一致。
|
||||
@@ -10,7 +10,21 @@ from types import ModuleType
|
||||
if not os.environ.get("CONFIG_DIR"):
|
||||
_isolated_config_dir = tempfile.mkdtemp(prefix="mp-test-config-")
|
||||
os.environ["CONFIG_DIR"] = _isolated_config_dir
|
||||
atexit.register(shutil.rmtree, _isolated_config_dir, ignore_errors=True)
|
||||
|
||||
def _cleanup_isolated_config_dir():
|
||||
"""进程退出时先释放 SQLite 连接池再删临时目录。
|
||||
|
||||
Windows 下 Engine 若仍持有 user.db 的文件锁,直接 rmtree 会因占用而静默失败
|
||||
(ignore_errors=True)、残留临时目录;先 dispose 释放连接再删可规避。
|
||||
"""
|
||||
try:
|
||||
from app.db import Engine
|
||||
Engine.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(_isolated_config_dir, ignore_errors=True)
|
||||
|
||||
atexit.register(_cleanup_isolated_config_dir)
|
||||
|
||||
# app.helper.sites 由独立仓库动态拉取(CI / 全新环境无该模块),而众多 app.chain.* /
|
||||
# app.modules.* 在 import 期依赖它。在此统一补一个最小垫片,省去各测试文件各自打桩;
|
||||
|
||||
@@ -174,8 +174,12 @@ class _OfflineProviderManager:
|
||||
base_url_preset_id=None,
|
||||
user_agent=None,
|
||||
use_proxy=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""按 provider 返回离线运行时结构,全程不触发网络请求。"""
|
||||
"""按 provider 返回离线运行时结构,全程不触发网络请求。
|
||||
|
||||
**kwargs 吸收未来真实 resolve_runtime 可能新增的关键字参数,避免签名扩展时替身抛 TypeError。
|
||||
"""
|
||||
normalized = (provider_id or "").strip().lower()
|
||||
return {
|
||||
"provider_id": normalized,
|
||||
|
||||
@@ -19,8 +19,11 @@ class TestTelegram(unittest.TestCase):
|
||||
模拟 telebot.TeleBot 以避免真实 API 调用:空 token 会让 Telegram.__init__ 提前返回、
|
||||
属性未初始化导致 send_* 抛错;这里用假 bot 让初始化完整且消息发送走内存桩。
|
||||
"""
|
||||
self.telebot_patcher = patch("app.modules.telegram.telegram.TeleBot")
|
||||
mock_telebot_cls = self.telebot_patcher.start()
|
||||
# 用 addCleanup 注册停桩:即使下方 Telegram(...) 初始化抛错、setUp 未跑完,
|
||||
# 已启动的 patcher 也会被清理,杜绝 patch 泄漏污染后续用例(tearDown 在 setUp 失败时不执行)。
|
||||
telebot_patcher = patch("app.modules.telegram.telegram.TeleBot")
|
||||
mock_telebot_cls = telebot_patcher.start()
|
||||
self.addCleanup(telebot_patcher.stop)
|
||||
self.mock_bot_instance = MagicMock()
|
||||
# get_me 用于初始化 bot 用户名,需返回带 username 的对象
|
||||
self.mock_bot_instance.get_me.return_value = MagicMock(username="test_bot")
|
||||
@@ -28,17 +31,13 @@ class TestTelegram(unittest.TestCase):
|
||||
|
||||
# send_medias/send_msg 发图时会经 ImageHelper().fetch_image 按 poster_path 真实下载海报,
|
||||
# 单测必须打桩,否则对 raw.githubusercontent.com 等外链发起真实 HTTP(外部 IO 不可接受且拖慢用例)。
|
||||
self.image_patcher = patch("app.modules.telegram.telegram.ImageHelper")
|
||||
mock_image_cls = self.image_patcher.start()
|
||||
image_patcher = patch("app.modules.telegram.telegram.ImageHelper")
|
||||
mock_image_cls = image_patcher.start()
|
||||
self.addCleanup(image_patcher.stop)
|
||||
mock_image_cls.return_value.fetch_image.return_value = b"fake-image-bytes"
|
||||
|
||||
self.telegram = Telegram(TELEGRAM_TOKEN="fake_token", TELEGRAM_CHAT_ID="fake_chat_id")
|
||||
|
||||
def tearDown(self):
|
||||
"""测试后清理:停止 TeleBot 与 ImageHelper 打桩。"""
|
||||
self.telebot_patcher.stop()
|
||||
self.image_patcher.stop()
|
||||
|
||||
def test_send_msg_success(self):
|
||||
"""测试发送普通消息成功"""
|
||||
# 调用send_msg方法
|
||||
|
||||
@@ -75,8 +75,14 @@ def setUpModule():
|
||||
patch.object(MoviePilotServerHelper, "report_recognize_share", new=MagicMock(return_value=None)),
|
||||
patch.object(MoviePilotServerHelper, "query_recognize_share", new=MagicMock(return_value=None)),
|
||||
])
|
||||
for patcher in _PATCHERS:
|
||||
patcher.start()
|
||||
try:
|
||||
for patcher in _PATCHERS:
|
||||
patcher.start()
|
||||
except Exception:
|
||||
# 任一 patcher.start() 失败(如重构致类/方法改名)时回滚已启动的桩并清空,
|
||||
# 避免半启动状态泄漏到其它测试模块。
|
||||
tearDownModule()
|
||||
raise
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
|
||||
Reference in New Issue
Block a user