test: 共享测试 harness 入 app/testing(网络守卫 + 引导)并统一 sys.modules 打桩原语 (#5888)

This commit is contained in:
InfinityPacer
2026-06-03 18:34:20 +08:00
committed by GitHub
parent 6405ff1191
commit 791f1fe4ac
12 changed files with 393 additions and 250 deletions

View File

@@ -1,43 +1,14 @@
"""pytest 全局引导:在 import 任何测试模块前把 CONFIG_DIR 指向临时目录并建表,隔离真实库。"""
import atexit
import os
import shutil
import sys
import tempfile
from types import ModuleType
"""pytest 全局引导:隔离 CONFIG_DIR、补 sites 垫片、建表、装载网络守卫。
# 必须早于首个 import app.*app.db 在导入时即按 CONFIG_PATH 连接 user.db
if not os.environ.get("CONFIG_DIR"):
_isolated_config_dir = tempfile.mkdtemp(prefix="mp-test-config-")
os.environ["CONFIG_DIR"] = _isolated_config_dir
引导与网络守卫均复用 ``app/testing`` 的共享 harness与插件仓 conftest 同源),
引导逻辑只在 ``app/testing`` 维护一处。
"""
# 必须早于首个 import app.db其在 import 期即按 CONFIG_PATH 连库prepare_backend 内部
# 先隔离 CONFIG_DIR、补 app.helper.sites 垫片再建表。app/testing 仅依赖标准库、import 不连库,
# 故此处先 import 再调用是安全的。
from app.testing.bootstrap import prepare_backend
def _cleanup_isolated_config_dir():
"""进程退出时先释放 SQLite 连接池再删临时目录。
prepare_backend()
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 期依赖它。在此统一补一个最小垫片,省去各测试文件各自打桩;
# 若真实模块已存在(本地已拉取)则 setdefault 不覆盖,不影响真实行为。
if "app.helper.sites" not in sys.modules:
try:
import app.helper.sites # noqa: F401 本地已拉取时用真实模块
except ModuleNotFoundError:
_sites_stub = ModuleType("app.helper.sites")
_sites_stub.SitesHelper = object
sys.modules["app.helper.sites"] = _sites_stub
# 必须在 CONFIG_DIR 设好之后再 import空库会让运行期查表报 no such table故建表
from app.db.init import init_db # noqa: E402
init_db()
# 复用共享 autouse 网络守卫;同一实现亦供各插件仓 conftest import 复用,避免逐仓维护
from app.testing.network_guard import block_real_network # noqa: E402,F401

View File

@@ -1,19 +1,16 @@
import sys
import asyncio
import json
import tempfile
import unittest
from types import ModuleType, SimpleNamespace
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
from app.testing.bootstrap import ensure_optional_stub
sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("dateparser", ModuleType("dateparser"))
if "Pinyin2Hanzi" not in sys.modules:
pinyin_module = ModuleType("Pinyin2Hanzi")
setattr(pinyin_module, "is_pinyin", lambda value: False)
sys.modules["Pinyin2Hanzi"] = pinyin_module
# 可选三方依赖在 CI / 全新环境可能未安装,补占位避免 app.modules.feishu 导入失败
ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.modules.feishu import FeishuModule
from app.modules.feishu.feishu import Feishu

View File

@@ -1,19 +1,6 @@
import sys
import unittest
from unittest.mock import Mock
for _module_name in (
"app.chain.mediaserver",
"app.db.models",
"app.db.user_oper",
"app.helper.message",
"app.utils.crypto",
):
if _module_name in sys.modules and not hasattr(
sys.modules[_module_name], "__file__"
):
del sys.modules[_module_name]
from app.chain.mediaserver import MediaServerChain
from app.schemas import MediaServerLibrary, MediaServerPlayItem
from app.utils.security import SecurityUtils

View File

@@ -131,14 +131,15 @@ class PluginHelperTest(TestCase):
self.skipTest(f"missing dependency: {exc}")
module_names = ["app.plugins.dynamicwechat.helper", "Crypto.Cipher._mode_cbc"]
previous_modules = {name: sys.modules.get(name) for name in module_names}
def fake_execute(_cmd):
for module_name in module_names:
sys.modules[module_name] = ModuleType(module_name)
return True, "ok"
try:
# patch.dict 进入时快照 sys.modules、退出时整体还原替代手写逐项 save/restore
# 保证 fake_execute 在安装窗口注入的运行态模块在用例结束后被清理、不污染其他用例
with patch.dict(sys.modules):
with tempfile.TemporaryDirectory() as temp_dir:
requirements_file = Path(temp_dir) / "requirements.txt"
requirements_file.write_text("demo-package\n", encoding="utf-8")
@@ -149,12 +150,6 @@ class PluginHelperTest(TestCase):
self.assertEqual("ok", message)
for module_name in module_names:
self.assertIn(module_name, sys.modules)
finally:
for module_name, previous_module in previous_modules.items():
if previous_module is None:
sys.modules.pop(module_name, None)
else:
sys.modules[module_name] = previous_module
def test_pip_install_serializes_concurrent_calls(self):
"""

View File

@@ -1,28 +1,15 @@
import asyncio
import importlib.machinery
import sys
import unittest
from types import SimpleNamespace
from types import ModuleType
from unittest.mock import AsyncMock, patch
from app.testing.bootstrap import ensure_optional_stub
def _stub_module(name: str, **attrs):
module = sys.modules.get(name)
if module is None:
module = ModuleType(name)
sys.modules[name] = module
for key, value in attrs.items():
setattr(module, key, value)
return module
_stub_module("qbittorrentapi", TorrentFilesList=list)
_stub_module("transmission_rpc", File=object)
_stub_module(
"psutil",
__spec__=importlib.machinery.ModuleSpec("psutil", loader=None),
)
# 可选三方依赖在 CI / 全新环境可能未安装,补占位(带用例所需属性)避免导入失败
ensure_optional_stub("qbittorrentapi", TorrentFilesList=list)
ensure_optional_stub("transmission_rpc", File=object)
ensure_optional_stub("psutil", __spec__=importlib.machinery.ModuleSpec("psutil", loader=None))
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent import ReplyMode

View File

@@ -7,6 +7,7 @@ from unittest import TestCase
from unittest.mock import patch
from app.schemas.types import MediaType
from app.testing import stub_modules
def _load_subscribe_chain_class():
@@ -16,13 +17,11 @@ def _load_subscribe_chain_class():
module = sys.modules[module_name]
return module, module.SubscribeChain
original_modules = {}
stub_deps = {}
def ensure_module(name: str, module: types.ModuleType):
"""临时替换模块依赖,并记录原模块以便加载完成后恢复"""
if name not in original_modules:
original_modules[name] = sys.modules.get(name)
sys.modules[name] = module
"""登记一个加载期临时替换模块;实际替换与精确还原由 stub_modules 在加载时统一处理"""
stub_deps[name] = module
return module
chain_module = ensure_module("app.chain", types.ModuleType("app.chain"))
@@ -298,18 +297,12 @@ def _load_subscribe_chain_class():
subscribe_path = Path(__file__).resolve().parents[1] / "app" / "chain" / "subscribe.py"
spec = importlib.util.spec_from_file_location(module_name, subscribe_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec and spec.loader
spec.loader.exec_module(module)
module._injected_modules = {
name: sys.modules.get(name)
for name in original_modules
}
for injected_name, original_module in original_modules.items():
if original_module is None:
sys.modules.pop(injected_name, None)
else:
sys.modules[injected_name] = original_module
# 加载期用 stub_modules 精确替换依赖、退出时统一还原module_name 非桩,缓存入 sys.modules 供复用
with stub_modules(stub_deps):
sys.modules[module_name] = module
spec.loader.exec_module(module)
module._injected_modules = {name: sys.modules.get(name) for name in stub_deps}
return module, module.SubscribeChain

View File

@@ -1,24 +1,17 @@
import asyncio
import sys
import unittest
from types import ModuleType
from unittest.mock import AsyncMock, patch
_ORIGINAL_STUBBED_MODULES = {}
from app.testing import stub_modules
def _stub_module(name: str, **attrs):
"""
安装临时 stub 模块,并记录原模块用于导入后恢复。
"""
if name not in _ORIGINAL_STUBBED_MODULES:
_ORIGINAL_STUBBED_MODULES[name] = sys.modules.get(name)
def _stub(name: str, **attrs) -> tuple:
"""构造带指定属性的占位模块,返回 ``(模块名, 模块)`` 供 :func:`stub_modules` 使用。"""
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
return module
return name, module
class _Dummy:
@@ -35,69 +28,44 @@ class _DummyError(Exception):
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
# 在 import 期用占位模块替换重依赖/外部模块import 完由 stub_modules 精确还原,避免污染其它用例
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
_stub("psutil"),
_stub("app.helper.sites", SitesHelper=_Dummy),
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
_stub("app.chain.search", SearchChain=_Dummy),
_stub("app.chain.system", SystemChain=_Dummy),
_stub("app.agent.llm", LLMHelper=_Dummy, LLMProviderManager=_Dummy,
LLMTestError=_DummyError, LLMTestTimeout=_DummyError,
render_auth_result_html=lambda success, message: message),
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
_stub("app.core.metainfo", MetaInfo=_Dummy),
_stub("app.core.module", ModuleManager=_Dummy),
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
_stub("app.db.models", User=_Dummy),
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
_stub("app.helper.llm", LLMHelper=_Dummy, LLMTestError=_DummyError, LLMTestTimeout=_DummyError),
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
_stub("app.helper.message", MessageHelper=_Dummy),
_stub("app.helper.progress", ProgressHelper=_Dummy),
_stub("app.helper.rule", RuleHelper=_Dummy),
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
_stub("app.helper.system", SystemHelper=_Dummy),
_stub("app.helper.image", ImageHelper=_Dummy),
_stub("app.scheduler", Scheduler=_Dummy),
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {})),
_stub("app.utils.crypto", HashUtils=_Dummy),
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
_stub("version", APP_VERSION="test"),
])
_stub_module("app.helper.sites", SitesHelper=_Dummy)
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
_stub_module("app.chain.search", SearchChain=_Dummy)
_stub_module("app.chain.system", SystemChain=_Dummy)
_stub_module(
"app.agent.llm",
LLMHelper=_Dummy,
LLMProviderManager=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
render_auth_result_html=lambda success, message: message,
)
_stub_module("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy)
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.module", ModuleManager=_Dummy)
_stub_module(
"app.core.security",
verify_apitoken=_Dummy,
verify_resource_token=_Dummy,
verify_token=_Dummy,
)
_stub_module("app.db.models", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module(
"app.db.user_oper",
get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
_stub_module("app.helper.rule", RuleHelper=_Dummy)
_stub_module("app.helper.server", MoviePilotServerHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test")
from app.api.endpoints import llm as system_endpoint
for _module_name, _module in _ORIGINAL_STUBBED_MODULES.items():
if _module is None:
sys.modules.pop(_module_name, None)
else:
sys.modules[_module_name] = _module
with stub_modules(_STUB_MODULES):
from app.api.endpoints import llm as system_endpoint
class LlmTestEndpointTest(unittest.TestCase):

View File

@@ -1,25 +1,18 @@
import asyncio
import ipaddress
import sys
import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
_ORIGINAL_STUBBED_MODULES = {}
from app.testing import stub_modules
def _stub_module(name: str, **attrs):
"""
安装临时 stub 模块,并记录原模块用于导入后恢复。
"""
if name not in _ORIGINAL_STUBBED_MODULES:
_ORIGINAL_STUBBED_MODULES[name] = sys.modules.get(name)
def _stub(name: str, **attrs) -> tuple:
"""构造带指定属性的占位模块,返回 ``(模块名, 模块)`` 供 :func:`stub_modules` 使用。"""
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
sys.modules[name] = module
return module
return name, module
class _Dummy:
@@ -36,67 +29,42 @@ class _DummyError(Exception):
self.duration_ms = duration_ms
for _module_name in ("pillow_avif", "aiofiles", "psutil"):
_stub_module(_module_name)
# 在 import 期用占位模块替换重依赖/外部模块import 完由 stub_modules 精确还原,避免污染其它用例
_STUB_MODULES = dict([
_stub("pillow_avif"),
_stub("aiofiles"),
_stub("psutil"),
_stub("app.helper.sites", SitesHelper=_Dummy),
_stub("app.chain.media", MediaChain=_Dummy),
_stub("app.chain.mediaserver", MediaServerChain=_Dummy),
_stub("app.chain.search", SearchChain=_Dummy),
_stub("app.chain.system", SystemChain=_Dummy),
_stub("app.core.event", eventmanager=_Dummy(), Event=_Dummy, EventManager=_Dummy),
_stub("app.core.metainfo", MetaInfo=_Dummy),
_stub("app.core.module", ModuleManager=_Dummy),
_stub("app.core.security", verify_apitoken=_Dummy, verify_resource_token=_Dummy, verify_token=_Dummy),
_stub("app.db.models", User=_Dummy),
_stub("app.db.systemconfig_oper", SystemConfigOper=_Dummy),
_stub("app.db.user_oper", get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy, get_current_active_user_async=_Dummy),
_stub("app.helper.llm", LLMHelper=_Dummy, LLMTestError=_DummyError, LLMTestTimeout=_DummyError),
_stub("app.helper.mediaserver", MediaServerHelper=_Dummy),
_stub("app.helper.message", MessageHelper=_Dummy),
_stub("app.helper.progress", ProgressHelper=_Dummy),
_stub("app.helper.rule", RuleHelper=_Dummy),
_stub("app.helper.server", MoviePilotServerHelper=_Dummy),
_stub("app.helper.system", SystemHelper=_Dummy),
_stub("app.helper.image", ImageHelper=_Dummy),
_stub("app.scheduler", Scheduler=_Dummy),
_stub("app.log", logger=_Dummy(), log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {})),
_stub("app.utils.crypto", HashUtils=_Dummy),
_stub("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy),
_stub("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test"),
])
_stub_module("app.helper.sites", SitesHelper=_Dummy)
_stub_module("app.chain.media", MediaChain=_Dummy)
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
_stub_module("app.chain.search", SearchChain=_Dummy)
_stub_module("app.chain.system", SystemChain=_Dummy)
_stub_module(
"app.core.event",
eventmanager=_Dummy(),
Event=_Dummy,
EventManager=_Dummy,
)
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
_stub_module("app.core.module", ModuleManager=_Dummy)
_stub_module(
"app.core.security",
verify_apitoken=_Dummy,
verify_resource_token=_Dummy,
verify_token=_Dummy,
)
_stub_module("app.db.models", User=_Dummy)
_stub_module("app.db.systemconfig_oper", SystemConfigOper=_Dummy)
_stub_module(
"app.db.user_oper",
get_current_active_superuser=_Dummy,
get_current_active_superuser_async=_Dummy,
get_current_active_user_async=_Dummy,
)
_stub_module(
"app.helper.llm",
LLMHelper=_Dummy,
LLMTestError=_DummyError,
LLMTestTimeout=_DummyError,
)
_stub_module("app.helper.mediaserver", MediaServerHelper=_Dummy)
_stub_module("app.helper.message", MessageHelper=_Dummy)
_stub_module("app.helper.progress", ProgressHelper=_Dummy)
_stub_module("app.helper.rule", RuleHelper=_Dummy)
_stub_module("app.helper.server", MoviePilotServerHelper=_Dummy)
_stub_module("app.helper.system", SystemHelper=_Dummy)
_stub_module("app.helper.image", ImageHelper=_Dummy)
_stub_module("app.scheduler", Scheduler=_Dummy)
_stub_module(
"app.log",
logger=_Dummy(),
log_settings=_Dummy(),
LogConfigModel=type("LogConfigModel", (), {}),
)
_stub_module("app.utils.crypto", HashUtils=_Dummy)
_stub_module("app.utils.http", RequestUtils=_Dummy, AsyncRequestUtils=_Dummy)
_stub_module("version", APP_VERSION="test", FRONTEND_VERSION="frontend-test")
from app.api.endpoints import system as system_endpoint
for _module_name, _module in _ORIGINAL_STUBBED_MODULES.items():
if _module is None:
sys.modules.pop(_module_name, None)
else:
sys.modules[_module_name] = _module
with stub_modules(_STUB_MODULES):
from app.api.endpoints import system as system_endpoint
class NettestSecurityTest(unittest.TestCase):

View File

@@ -0,0 +1,62 @@
"""共享测试引导工具的回归用例。"""
from __future__ import annotations
import builtins
import types
from pathlib import Path
from app.testing import bootstrap
def test_isolate_config_cleanup_uses_loaded_db_module_without_late_import(monkeypatch):
"""清理回调只读取已加载模块,避免解释器关停期触发二次导入。"""
captured = {}
import_calls = []
def fake_import(name, *args, **kwargs):
"""记录清理回调是否试图重新导入数据库模块。"""
if name == "app.db":
import_calls.append(name)
return original_import(name, *args, **kwargs)
def fake_register(func):
"""截获 atexit 回调,便于直接验证清理行为。"""
captured["cleanup"] = func
monkeypatch.setattr(bootstrap, "_isolated_config_dir", None)
monkeypatch.delenv("CONFIG_DIR", raising=False)
monkeypatch.setattr(bootstrap.tempfile, "mkdtemp", lambda prefix: "/tmp/mp-test-config-demo")
monkeypatch.setattr(bootstrap.shutil, "rmtree", lambda *args, **kwargs: None)
monkeypatch.setattr(bootstrap.atexit, "register", fake_register)
original_import = builtins.__import__
monkeypatch.setattr(builtins, "__import__", fake_import)
bootstrap.isolate_config_dir()
captured["cleanup"]()
assert import_calls == []
def test_mark_plugin_generation_prefers_pathlib_item_path():
"""pytest 新版 item.path 可独立驱动 v1/v2 marker 标记。"""
class FakeItem:
"""只暴露 pytest 7+ 的 path 属性,模拟新版收集对象。"""
def __init__(self, value: str):
self.path = Path(value)
self.markers = []
def add_marker(self, marker):
"""记录被添加的 marker。"""
self.markers.append(marker)
pytest_module = types.SimpleNamespace(mark=types.SimpleNamespace(v1="v1", v2="v2"))
v2_item = FakeItem("/repo/tests/v2/test_demo.py")
v1_item = FakeItem("/repo/tests/v1/test_demo.py")
bootstrap.mark_plugin_generation([v2_item, v1_item], pytest_module)
assert v2_item.markers == ["v2"]
assert v1_item.markers == ["v1"]