Become a sponsor

项目采用三层测试体系:契约快照测试、单元测试、集成测试。测试使用 pytest 框架,分层覆盖不同维度的质量保障。
三层测试体系
tests/
├── contract/ # 契约快照测试
│ ├── golden/ # 快照基线文件(JSON)
│ │ ├── routes.json
│ │ ├── permissions.json
│ │ └── tables.json
│ ├── helpers.py # 测试辅助函数
│ ├── test_routes_snapshot.py # 路由路径快照
│ ├── test_permission_snapshot.py # 权限标识快照
│ ├── test_tablename_snapshot.py # 表名快照
│ └── test_schema_sql_consistency.py # 模型与 SQL 一致性
├── unit/ # 单元测试
│ ├── core/ # 核心模块测试
│ │ ├── test_jwt.py
│ │ ├── test_pagination.py
│ │ ├── test_response.py
│ │ └── ...
│ ├── middleware/ # 中间件测试
│ │ ├── test_operation_log.py
│ │ └── ...
│ ├── modules/ # 业务模块测试
│ │ ├── conftest.py # service 层公共 fixture
│ │ ├── test_upload_service.py
│ │ ├── test_base_service_page.py
│ │ └── ...
│ ├── utils/ # 工具函数测试
│ │ ├── test_ip_trusted_proxy.py
│ │ ├── test_ip2region.py
│ │ └── ...
│ └── scripts/ # 脚本测试
│ └── test_migrate_db.py
├── integration/ # 集成测试(默认跳过)
│ └── api/
│ ├── test_smoke.py
│ └── test_transaction.py
├── test_bugfixes.py # Bug 回归测试
├── test_rich_text.py # 富文本工具测试
└── test_middleware_smoke.py # 中间件冒烟测试# pytest.ini
[pytest]
minversion = 7.0
addopts = -ra --strict-markers -m "not integration"
testpaths = tests
pythonpath = src
markers =
contract: 契约快照测试(路由/权限串/表名),守护前端与数据契约
integration: 需要真实 MySQL/Redis 的集成测试,默认跳过pythonpath
pythonpath = src 让测试文件可以直接 from modules.xxx import ... 和 from core.xxx import ...,无需手动设置 PYTHONPATH。
验证路由路径、权限标识、数据库表名等契约不变。快照文件存储在 tests/contract/golden/ 目录。
# tests/contract/test_routes_snapshot.py
"""路由契约快照:守护全部 API 路径与 HTTP 方法不变。"""
import pytest
from tests.contract.helpers import collect_routes, compare_snapshot
pytestmark = pytest.mark.contract
def test_routes_snapshot():
compare_snapshot("routes.json", collect_routes())# tests/contract/test_permission_snapshot.py
"""权限契约快照:守护全部 @permission_required 权限串不变。"""
import pytest
from tests.contract.helpers import collect_permissions, compare_snapshot
pytestmark = pytest.mark.contract
def test_permissions_snapshot():
compare_snapshot("permissions.json", collect_permissions())# tests/contract/helpers.py
import json
import os
GOLDEN_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "golden")
UPDATE_GOLDEN = os.getenv("UPDATE_GOLDEN") == "1"
def collect_routes():
"""通过 openapi schema 收集全部 (METHOD /path),去重排序。"""
from core.app import create_app
app = create_app()
spec = app.openapi()
return sorted(
f"{method.upper()} {path}"
for path, methods in spec["paths"].items()
for method in methods
)
def collect_permissions():
"""静态扫描源码中的 @permission_required("sys:...") 权限串。"""
import glob
import re
perms = set()
PERMISSION_RE = re.compile(r'@permission_required\(\s*["\'](sys:[^"\']+)["\']')
for root in ("src/api/v1/endpoints", "src/modules"):
for path in glob.glob(os.path.join(root, "**", "*.py"), recursive=True):
with open(path, encoding="utf-8") as f:
perms.update(PERMISSION_RE.findall(f.read()))
return sorted(perms)
def compare_snapshot(name: str, actual: list):
"""与 golden 对比;UPDATE_GOLDEN=1 时重新生成并跳过断言。"""
path = os.path.join(GOLDEN_DIR, name)
if UPDATE_GOLDEN:
os.makedirs(GOLDEN_DIR, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(actual, f, ensure_ascii=False, indent=2)
return
with open(path, encoding="utf-8") as f:
golden = json.load(f)
assert actual == golden, (
f"契约快照 [{name}] 不一致!\n"
f"新增: {sorted(set(actual) - set(golden))}\n"
f"缺失: {sorted(set(golden) - set(actual))}"
)# 更新全部快照基线
UPDATE_GOLDEN=1 pytest tests/contract -m contract
# 更新后运行一次验证
pytest tests/contract -m contract验证单个函数/方法的逻辑正确性,不依赖外部服务。
| 类型 | 文件命名 | 函数命名 |
|---|---|---|
| 契约测试 | test_*.py | test_* |
| 单元测试 | test_*.py | test_* |
| 集成测试 | test_*.py | test_* |
函数命名规则
测试函数名应清晰描述被测行为:
test_upload_file_rejects_svg:验证上传文件拒绝 SVGtest_is_private_ip_handles_malformed_ip:验证畸形 IP 不抛异常test_batch_delete_soft_deletes:验证批量删除执行软删除使用 monkeypatch 替换模块级变量,隔离测试环境:
# tests/unit/modules/test_upload_service.py
import asyncio
import json
from modules.upload import service as upload_service
from modules.upload.service import _save_upload, upload_file
class _FakeUpload:
"""模拟异步分块读取的 UploadFile。"""
def __init__(self, filename, data=b''):
self.filename = filename
self._data = data
async def read(self, size=65536):
data = self._data
self._data = b''
return data
def test_upload_file_rejects_svg(monkeypatch):
"""SVG 被 BANNED_UPLOAD_EXTS 硬拒绝"""
monkeypatch.setattr(upload_service, 'UPLOAD_ALLOWED_EXTS', '.svg')
result = asyncio.run(upload_file(_FakeUpload('logo.svg', b'<svg/>')))
body = json.loads(result.body)
assert body['ok'] is False
def test_save_upload_rejects_svg_even_if_config_allows():
"""兜底硬拒绝:即使配置放行 .svg,落盘前一律拒绝"""
content = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
result = _save_upload(content, 'a.svg', '.svg')
body = json.loads(result.body)
assert body['ok'] is False
assert 'SVG' in body['msg']# tests/unit/utils/test_ip2region.py
from utils.ip2region import _is_private_ip, get_ip_location, get_ip_region
def test_is_private_ip_handles_malformed_ip():
"""畸形 IP 不再抛 ValueError,按非内网处理"""
assert _is_private_ip('172.abc') is False
assert _is_private_ip('172.') is False
def test_get_ip_location_malformed_ip_degrades_to_unknown():
"""畸形 IP 不抛异常,降级返回"未知" """
assert get_ip_location('172.abc') == '未知'
def test_is_private_ip_private_ranges():
assert _is_private_ip('172.16.5.5') is True
assert _is_private_ip('172.31.5.5') is True
assert _is_private_ip('172.32.5.5') is False
assert _is_private_ip('10.1.2.3') is True
assert _is_private_ip('192.168.1.1') is True
assert _is_private_ip('8.8.8.8') is False# tests/unit/utils/test_ip_trusted_proxy.py
import types
from utils import ip as ip_mod
from utils.ip import get_client_ip
def _make_request(client_host, xff=None):
"""构造模拟 Request 对象。"""
req = types.SimpleNamespace()
req.client = types.SimpleNamespace(host=client_host)
req.headers = {'X-Forwarded-For': xff} if xff else {}
return req
def test_proxy_count_zero_ignores_xff(monkeypatch):
"""TRUSTED_PROXY_COUNT=0:不信任代理头"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 0)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'127.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_not_in_whitelist_rejects_xff(monkeypatch):
"""直连对端不在白名单 → XFF 被忽略"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('203.0.113.9', xff='1.2.3.4')) == '203.0.113.9'
def test_peer_in_whitelist_trusts_xff(monkeypatch):
"""单层代理:对端在白名单 → 信任 XFF"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='1.2.3.4')) == '1.2.3.4'
def test_peer_in_whitelist_spoofed_left_entry_ignored(monkeypatch):
"""客户端伪造条目只会在 XFF 左侧"""
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_COUNT', 1)
monkeypatch.setattr(ip_mod, 'TRUSTED_PROXY_IPS', frozenset({'10.0.0.1'}))
assert get_client_ip(_make_request('10.0.0.1', xff='6.6.6.6, 1.2.3.4')) == '1.2.3.4'# tests/unit/modules/conftest.py
"""service 层单元测试公共 fixture:构造最小 Request 对象。"""
import types
import pytest
@pytest.fixture
def fake_request():
"""模拟 FastAPI Request,仅提供 service 层用到的字段。"""
class FR:
state = types.SimpleNamespace(user_id=1, username="tester", realname="测试员")
query_params = {}
return FR()验证模块间协作和真实数据库操作(需真实 MySQL/Redis,默认跳过):
import pytest
@pytest.mark.integration
class TestPositionIntegration:
"""岗位模块集成测试"""
def test_crud_flow(self, client, auth_headers):
"""验证完整的增删改查流程"""
# 添加
resp = client.post('/api/v1/position/add', json={
'name': '集成测试岗位', 'status': 1, 'sort': 1
}, headers=auth_headers)
assert resp.json()['ok'] is True
# 查询
resp = client.get('/api/v1/position/page?pageNo=1&pageSize=10', headers=auth_headers)
assert resp.json()['ok'] is True
assert resp.json()['data']['total'] > 0集成测试环境
集成测试需要真实的 MySQL 和 Redis 实例,默认跳过。运行前确保 .env 配置正确,且数据库中已有基础数据。
# 全部测试(跳过集成测试)
pytest
make test
# 契约测试
pytest tests/contract -m contract
make test-contract
# 单元测试
pytest tests/unit
make test-unit
# 集成测试(需真实 MySQL/Redis)
pytest tests/integration -m integration
make test-integration
# 运行指定文件
pytest tests/unit/modules/test_upload_service.py
# 运行指定函数
pytest tests/unit/modules/test_upload_service.py::test_upload_file_rejects_svg
# 显示详细输出
pytest -v
# 显示 print 输出
pytest -s# 安装覆盖率插件
pip install pytest-cov
# 运行测试并生成覆盖率报告
pytest --cov=src --cov-report=html
# 查看报告
open htmlcov/index.html覆盖率目标
测试规范采用三层体系:契约快照测试保障路由/权限不变、单元测试验证单函数逻辑、集成测试验证模块协作。测试文件命名 test_*.py,函数命名 test_*。pytest 标记区分测试类型,默认跳过集成测试。契约快照通过 UPDATE_GOLDEN=1 环境变量更新基线。