# 测试规范 ## 概述 项目采用三层测试体系:契约快照测试、单元测试、集成测试。测试使用 pytest 框架,分层覆盖不同维度的质量保障。 :::tip 三层测试体系 1. 契约测试(Contract):验证路由、权限、表名等契约不变 2. 单元测试(Unit):验证单个函数/方法的逻辑正确性 3. 集成测试(Integration):验证模块间协作、真实数据库操作 ::: ## 目录结构 ``` 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.ini [pytest] minversion = 7.0 addopts = -ra --strict-markers -m "not integration" testpaths = tests pythonpath = src markers = contract: 契约快照测试(路由/权限串/表名),守护前端与数据契约 integration: 需要真实 MySQL/Redis 的集成测试,默认跳过 ``` :::tip pythonpath `pythonpath = src` 让测试文件可以直接 `from src.modules.xxx import ...` 和 `from src.core.xxx import ...`,无需手动设置 PYTHONPATH。 ::: ## 契约快照测试 验证路由路径、权限标识、数据库表名等契约不变。快照文件存储在 `tests/contract/golden/` 目录。 ### 路由快照 ```python # 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()) ``` ### 权限快照 ```python # tests/contract/test_permission_snapshot.py """权限契约快照:守护全部 @check_permission 权限串不变。""" 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()) ``` ### 辅助函数 ```python # 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(): """通过 Flask URL rules 收集全部 (METHOD /path),去重排序。""" from src.app import create_app app = create_app() routes = [] with app.app_context(): for rule in app.url_map.iter_rules(): if rule.endpoint != 'static': for method in rule.methods: if method not in ('HEAD', 'OPTIONS'): routes.append(f"{method} {rule.rule}") return sorted(routes) def collect_permissions(): """静态扫描源码中的 @check_permission("sys:...") 权限串。""" import glob import re perms = set() PERMISSION_RE = re.compile(r'@check_permission\(\s*["\'](sys:[^"\']+)["\']') for root in ("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))}" ) ``` ### 更新快照 ```bash # 更新全部快照基线 UPDATE_GOLDEN=1 pytest tests/contract -m contract # 更新后运行一次验证 pytest tests/contract -m contract ``` ## 单元测试 验证单个函数/方法的逻辑正确性,不依赖外部服务。 ### 测试命名规范 | 类型 | 文件命名 | 函数命名 | |------|---------|---------| | 契约测试 | `test_*.py` | `test_*` | | 单元测试 | `test_*.py` | `test_*` | | 集成测试 | `test_*.py` | `test_*` | :::tip 函数命名规则 测试函数名应清晰描述被测行为: - `test_upload_file_rejects_svg`:验证上传文件拒绝 SVG - `test_is_private_ip_handles_malformed_ip`:验证畸形 IP 不抛异常 - `test_batch_delete_soft_deletes`:验证批量删除执行软删除 ::: ### monkeypatch 隔离依赖 使用 `monkeypatch` 替换模块级变量,隔离测试环境: ```python # tests/unit/modules/test_upload_service.py import json from src.modules.upload import service as upload_service from src.modules.upload.service import _save_upload, upload_file class _FakeUpload: """模拟文件上传对象。""" def __init__(self, filename, data=b''): self.filename = filename self._data = data 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 = upload_file(_FakeUpload('logo.svg', b'')) body = json.loads(result.get_data(as_text=True)) assert body['ok'] is False def test_save_upload_rejects_svg_even_if_config_allows(): """兜底硬拒绝:即使配置放行 .svg,落盘前一律拒绝""" content = b'' result = _save_upload(content, 'a.svg', '.svg') body = json.loads(result.get_data(as_text=True)) assert body['ok'] is False assert 'SVG' in body['msg'] ``` ### 畸形输入防御测试 ```python # tests/unit/utils/test_ip2region.py from src.common.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 ``` ### service 层公共 fixture ```python # tests/unit/modules/conftest.py """service 层单元测试公共 fixture:构造最小 Request 对象。""" import types import pytest @pytest.fixture def fake_request(): """模拟 Flask Request,仅提供 service 层用到的字段。""" class FR: args = {} form = {} json = {} return FR() ``` ## 集成测试 验证模块间协作和真实数据库操作(需真实 MySQL/Redis,默认跳过): ```python 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['count'] > 0 ``` :::warning 集成测试环境 集成测试需要真实的 MySQL 和 Redis 实例,默认跳过。运行前确保 `.env` 配置正确,且数据库中已有基础数据。 ::: ## 运行命令 ```bash # 全部测试(跳过集成测试) 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 ``` ## 测试覆盖率 ```bash # 安装覆盖率插件 pip install pytest-cov # 运行测试并生成覆盖率报告 pytest --cov=src --cov-report=html # 查看报告 open htmlcov/index.html ``` :::tip 覆盖率目标 - Repository 层:80%+ - Service 层:70%+ - Router 层:由契约测试保障路由正确性 ::: ## 总结 测试规范采用三层体系:契约快照测试保障路由/权限不变、单元测试验证单函数逻辑、集成测试验证模块协作。测试文件命名 `test_*.py`,函数命名 `test_*`。pytest 标记区分测试类型,默认跳过集成测试。契约快照通过 `UPDATE_GOLDEN=1` 环境变量更新基线。