578 lines
21 KiB
Markdown
578 lines
21 KiB
Markdown
# Life Echo API Pytest 测试覆盖计划
|
||
|
||
本计划假设架构重构计划已完成,项目已迁移至 `app/` 下的 feature-first 结构。
|
||
|
||
策略:**删除全部旧 unittest,用 pytest 从零重写。**
|
||
|
||
---
|
||
|
||
## 一、旧测试处置
|
||
|
||
### 删除(13 个 unittest 文件)
|
||
|
||
全部删除。重写时参考其覆盖场景:
|
||
|
||
| 旧文件 | 覆盖场景 | 重写到 |
|
||
|--------|----------|--------|
|
||
| `test_memoir_image_settings.py` | from_env 默认值、无效整数回退 | `unit/adapters/test_image_gen_liblib.py` |
|
||
| `test_memoir_image_schema.py` | normalize 无效 status、缺字段 | `unit/features/test_memoir_service.py` |
|
||
| `test_memoir_image_parser.py` | 占位符解析、双大括号 | `unit/features/test_memoir_service.py` |
|
||
| `test_memoir_image_provider.py` | 签名、提交、轮询、下载 | `unit/adapters/test_image_gen_liblib.py` |
|
||
| `test_memoir_image_storage.py` | 单例、上传、下载 URL、错误 | `unit/adapters/test_storage_tencent_cos.py` |
|
||
| `test_memoir_image_prompting.py` | 无 LLM 回退、JSON 解析 | `unit/features/test_memoir_service.py` |
|
||
| `test_memoir_image_bootstrap.py` | 启用/禁用、动态上限 | `unit/features/test_memoir_service.py` |
|
||
| `test_generate_chapter_images_task.py` | 锁、重试、幂等、JPEG->PNG | `unit/tasks/test_memoir_tasks.py` |
|
||
| `test_process_memoir_segments_image_enqueue.py` | markdown JSON、入队、禁用 | `unit/tasks/test_memoir_tasks.py` |
|
||
| `test_memoir_tasks_redis.py` | Redis 锁复用 | `unit/tasks/test_memoir_tasks.py` |
|
||
| `test_pdf_service_images.py` | 图片宽高比、签名失败跳过 | `unit/features/test_memoir_service.py` |
|
||
| `test_chapters_router_images.py` | 签名 URL、畸形资产 | `unit/features/test_memoir_router.py` |
|
||
| `test_websocket_baseline.py` | 无效 token、文本/音频/转写、结束、乱序、去重、重连 | `unit/features/test_ws_pipeline.py` |
|
||
|
||
### 移出(2 个手工脚本)
|
||
|
||
- `test_sms_verification.py` -> `scripts/manual/sms_verification.py`
|
||
- `test_conversation.py` -> `scripts/manual/conversation_e2e.py`
|
||
|
||
---
|
||
|
||
## 二、测试目录结构
|
||
|
||
```
|
||
api/
|
||
pyproject.toml # 生产依赖 + dev 依赖 + pytest/coverage 配置
|
||
uv.lock # lockfile,纳入版本控制
|
||
Makefile
|
||
|
||
tests/
|
||
conftest.py # unit 层 fixtures
|
||
factories.py # make_user(), make_conversation() 等
|
||
fakes/ # port fake 实现(无 __init__.py)
|
||
sms.py
|
||
llm.py
|
||
storage.py
|
||
image_gen.py
|
||
tts.py
|
||
asr.py
|
||
helpers.py
|
||
|
||
unit/
|
||
core/
|
||
test_config.py
|
||
test_security.py
|
||
test_errors.py
|
||
test_middleware.py
|
||
adapters/
|
||
test_sms_tencent.py
|
||
test_llm_deepseek.py
|
||
test_image_gen_liblib.py
|
||
test_storage_tencent_cos.py
|
||
test_tts_openai.py
|
||
test_asr_whisper.py
|
||
test_asr_tencent.py
|
||
test_payment_wechat.py
|
||
test_payment_alipay.py
|
||
features/
|
||
test_auth_service.py
|
||
test_auth_router.py
|
||
test_conversation_service.py
|
||
test_conversation_router.py
|
||
test_ws_connection_manager.py
|
||
test_ws_message_types.py
|
||
test_ws_pipeline.py
|
||
test_ws_profile_collector.py
|
||
test_ws_quota_guard.py
|
||
test_memoir_service.py
|
||
test_memoir_router.py
|
||
test_payment_service.py
|
||
test_payment_router.py
|
||
test_user_service.py
|
||
test_user_router.py
|
||
test_quota_service.py
|
||
test_quota_router.py
|
||
test_plan_service.py
|
||
test_plan_router.py
|
||
test_content_router.py
|
||
agents/
|
||
test_conversation_agent.py
|
||
test_memory_agent.py
|
||
tasks/
|
||
test_memoir_tasks.py
|
||
|
||
integration/
|
||
conftest.py
|
||
test_auth_repo.py
|
||
test_conversation_repo.py
|
||
test_memoir_repo.py
|
||
test_payment_repo.py
|
||
test_user_repo.py
|
||
test_quota_repo.py
|
||
test_auth_service_write.py
|
||
test_payment_service_write.py
|
||
test_conversation_service_write.py
|
||
test_memoir_tasks_db.py
|
||
test_auth_flow.py
|
||
test_conversation_flow.py
|
||
test_payment_flow.py
|
||
test_alembic.py
|
||
```
|
||
|
||
### 设计原则
|
||
|
||
- **无 `__init__.py`**:测试目录不放 `__init__.py`,避免包导入副作用。
|
||
- **repo 测试全部放 integration/**:不用 SQLite 模拟 Postgres。SQLite 掩盖 JSON/JSONB、约束、排序、方言差异。
|
||
- **router 测试在 unit/**:override service stub,不跑真实 service/repo/DB。
|
||
- **service 测试分两层**:unit mock repo 测编排;integration 真实 Postgres 测关键写路径的事务边界。
|
||
- features 下文件名 flat,减少嵌套。
|
||
|
||
---
|
||
|
||
## 三、基建代码
|
||
|
||
### 3.1 pyproject.toml
|
||
|
||
生产依赖通过 `uv add <pkg>` 添加到 `[project.dependencies]`,dev 依赖通过 `uv add --dev <pkg>` 添加到 `[dependency-groups]`。禁止手动编辑依赖列表。
|
||
|
||
用 `uv sync --dev` 安装全部依赖(含 dev),`uv.lock` 纳入版本控制。不在 `addopts` 中全局过滤 marker。
|
||
|
||
当前开发工具链职责约定:
|
||
|
||
- `ruff`:负责 `lint` + `format`
|
||
- `pytest`:统一测试入口
|
||
- `pytest-asyncio`:负责 `async def` 测试和异步 fixture
|
||
- `pytest-cov`:负责覆盖率统计
|
||
- `pyright`:负责类型检查,但不替代测试和 `lint`
|
||
|
||
### 3.2 Makefile
|
||
|
||
```makefile
|
||
.PHONY: lint format typecheck test test-unit test-integration test-all test-cov
|
||
|
||
lint: ## 代码检查
|
||
uv run ruff check .
|
||
|
||
format: ## 代码格式化
|
||
uv run ruff format .
|
||
|
||
typecheck: ## 类型检查
|
||
uv run pyright
|
||
|
||
test: test-unit ## 默认只跑 unit
|
||
|
||
test-unit: ## 零外部依赖
|
||
uv run pytest tests/unit/ -x -q
|
||
|
||
test-integration: ## 需要 Postgres + Redis
|
||
uv run pytest tests/integration/ -x -q
|
||
|
||
test-all: ## 全部
|
||
uv run pytest tests/ -x -q
|
||
|
||
test-cov: ## 带覆盖率
|
||
uv run pytest tests/ --cov=app --cov-report=term-missing
|
||
```
|
||
|
||
### 3.3 tests/conftest.py(unit 测试用)
|
||
|
||
关键设计:router 测试 override **service**(不是 port),service 测试直接构造实例注入 fake ports。两者不重叠。
|
||
|
||
```python
|
||
import pytest
|
||
from unittest.mock import AsyncMock
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
|
||
# ---- Service stubs ----
|
||
# 每个 feature service 一个 AsyncMock,预设 return_value 匹配方法签名。
|
||
# 以 auth 为例,其余 feature 同理。
|
||
@pytest.fixture
|
||
def auth_service_stub():
|
||
stub = AsyncMock()
|
||
stub.register.return_value = {"access_token": "t", "refresh_token": "r"}
|
||
stub.login.return_value = {"access_token": "t", "refresh_token": "r"}
|
||
return stub
|
||
|
||
@pytest.fixture
|
||
def conversation_service_stub():
|
||
return AsyncMock()
|
||
|
||
# memoir_service_stub, payment_service_stub, user_service_stub,
|
||
# quota_service_stub, plan_service_stub 同理,均为 AsyncMock()
|
||
|
||
|
||
# ---- Fake ports(service 层单测用)----
|
||
# 每个 port 一个 fixture,从 tests/fakes/ 导入。
|
||
@pytest.fixture
|
||
def fake_sms():
|
||
from tests.fakes.sms import InMemorySmsSender
|
||
return InMemorySmsSender()
|
||
|
||
@pytest.fixture
|
||
def fake_llm():
|
||
from tests.fakes.llm import StubLLMProvider
|
||
return StubLLMProvider(default_response="测试回复")
|
||
|
||
# fake_storage, fake_image_gen, fake_tts, fake_asr 同理
|
||
|
||
|
||
# ---- Router 测试用 app(override service 层)----
|
||
@pytest.fixture
|
||
def app(auth_service_stub, conversation_service_stub, ...):
|
||
from app.main import create_app
|
||
from app.features.auth.deps import get_auth_service
|
||
from app.features.conversation.deps import get_conversation_service
|
||
# ... 其余 feature deps
|
||
|
||
application = create_app()
|
||
application.dependency_overrides[get_auth_service] = lambda: auth_service_stub
|
||
application.dependency_overrides[get_conversation_service] = lambda: conversation_service_stub
|
||
# ... 其余 feature overrides
|
||
return application
|
||
|
||
@pytest.fixture
|
||
async def client(app):
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
# ---- 认证 fixture ----
|
||
# 必须 override get_current_user 本身,不能只提供 JWT header。
|
||
# get_current_user 通常会查 DB 加载用户,只发 header 会泄漏真实依赖。
|
||
@pytest.fixture
|
||
def stub_current_user():
|
||
from tests.factories import make_user
|
||
return make_user(id="test-user-id", subscription_type="free")
|
||
|
||
@pytest.fixture
|
||
def authenticated_app(app, stub_current_user):
|
||
from app.core.dependencies import get_current_user
|
||
app.dependency_overrides[get_current_user] = lambda: stub_current_user
|
||
return app
|
||
|
||
@pytest.fixture
|
||
async def auth_client(authenticated_app):
|
||
async with AsyncClient(
|
||
transport=ASGITransport(app=authenticated_app),
|
||
base_url="http://test",
|
||
) as c:
|
||
yield c
|
||
```
|
||
|
||
### 3.4 integration/conftest.py(savepoint 隔离)
|
||
|
||
```python
|
||
import pytest
|
||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||
from sqlalchemy import event, create_engine
|
||
from app.core.db import Base
|
||
|
||
@pytest.fixture(scope="session")
|
||
async def pg_engine():
|
||
import os
|
||
url = os.environ["TEST_DATABASE_URL"]
|
||
engine = create_async_engine(url)
|
||
|
||
from alembic.config import Config
|
||
from alembic.command import upgrade
|
||
cfg = Config("alembic.ini")
|
||
cfg.set_main_option("sqlalchemy.url", url.replace("+asyncpg", "+psycopg"))
|
||
upgrade(cfg, "head")
|
||
|
||
yield engine
|
||
await engine.dispose()
|
||
|
||
@pytest.fixture
|
||
async def pg_session(pg_engine):
|
||
"""
|
||
连接级事务 + savepoint。
|
||
service 内部 commit() 变为 savepoint release,
|
||
测试结束时外层事务 rollback 撤销一切。
|
||
|
||
硬性前提:被测代码必须复用此 session,不得自行创建 session。
|
||
自行创建 session 的代码(如 Celery 任务)不走此 fixture。
|
||
"""
|
||
conn = await pg_engine.connect()
|
||
txn = await conn.begin()
|
||
session = AsyncSession(bind=conn)
|
||
|
||
nested = await conn.begin_nested()
|
||
|
||
@event.listens_for(session.sync_session, "after_transaction_end")
|
||
def restart_savepoint(s, transaction):
|
||
nonlocal nested
|
||
if transaction.nested and not transaction._parent.nested:
|
||
nested = conn.sync_connection.begin_nested()
|
||
|
||
yield session
|
||
|
||
await session.close()
|
||
await txn.rollback()
|
||
await conn.close()
|
||
|
||
# ---- Celery 任务用同步 session(不走 savepoint,用 truncate 清理)----
|
||
@pytest.fixture
|
||
def sync_session(pg_engine):
|
||
import os
|
||
sync_url = os.environ["TEST_DATABASE_URL"].replace("+asyncpg", "+psycopg")
|
||
sync_engine = create_engine(sync_url)
|
||
from sqlalchemy.orm import Session
|
||
with Session(sync_engine) as session:
|
||
yield session
|
||
with sync_engine.begin() as conn:
|
||
for table in reversed(Base.metadata.sorted_tables):
|
||
conn.execute(table.delete())
|
||
|
||
# ---- Integration app(注入 pg_session + fake ports)----
|
||
@pytest.fixture
|
||
async def integration_app(pg_session, fake_sms, fake_llm, fake_storage,
|
||
fake_image_gen, fake_tts, fake_asr):
|
||
from app.main import create_app
|
||
from app.core.db import get_async_db
|
||
from app.core.dependencies import (
|
||
get_sms_sender, get_llm_provider, get_storage,
|
||
get_image_generator, get_tts_provider, get_asr_provider,
|
||
)
|
||
application = create_app()
|
||
application.dependency_overrides[get_async_db] = lambda: pg_session
|
||
application.dependency_overrides[get_sms_sender] = lambda: fake_sms
|
||
application.dependency_overrides[get_llm_provider] = lambda: fake_llm
|
||
application.dependency_overrides[get_storage] = lambda: fake_storage
|
||
application.dependency_overrides[get_image_generator] = lambda: fake_image_gen
|
||
application.dependency_overrides[get_tts_provider] = lambda: fake_tts
|
||
application.dependency_overrides[get_asr_provider] = lambda: fake_asr
|
||
yield application
|
||
application.dependency_overrides.clear()
|
||
|
||
@pytest.fixture
|
||
async def integration_client(integration_app):
|
||
async with AsyncClient(
|
||
transport=ASGITransport(app=integration_app),
|
||
base_url="http://test",
|
||
) as c:
|
||
yield c
|
||
```
|
||
|
||
### 3.5 factories.py
|
||
|
||
不使用 factory-boy(`SQLAlchemyModelFactory` 默认为同步 Session,和 AsyncSession 配合需大量封装)。用简单同步 helper 函数:
|
||
|
||
```python
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from app.features.user.models import User
|
||
from app.features.conversation.models import Conversation
|
||
|
||
_seq = 0
|
||
|
||
def make_user(**overrides) -> User:
|
||
global _seq; _seq += 1
|
||
defaults = dict(
|
||
id=str(uuid.uuid4()),
|
||
phone=f"1380000{_seq:04d}",
|
||
nickname=f"用户{_seq}",
|
||
subscription_type="free",
|
||
created_at=datetime.now(timezone.utc),
|
||
)
|
||
defaults.update(overrides)
|
||
return User(**defaults)
|
||
|
||
# make_conversation, make_segment, make_book, make_chapter,
|
||
# make_memoir_state, make_order, make_refresh_token, make_sms_code 同理。
|
||
# 同步构造 model 实例,integration 测试中 session.add() + await session.flush() 入库。
|
||
```
|
||
|
||
### 3.6 Fake Port 实现
|
||
|
||
`tests/fakes/` 下每个 port 一个文件,无 `__init__.py`,实现完整 Protocol 签名:
|
||
|
||
- **sms.py** -- `InMemorySmsSender`:`sent: list` 记录历史,`should_fail` 可切换失败模式
|
||
- **llm.py** -- `StubLLMProvider`:`default_response`,`responses: list`(按序消耗),`call_count`
|
||
- **storage.py** -- `InMemoryStorage`:`dict[str, bytes]` 后端
|
||
- **image_gen.py** -- `StubImageGenerator`:`generate()` 返回预设结果,`check_status()` 按调用次数切换
|
||
- **tts.py** -- `StubTTSProvider`:返回固定 bytes
|
||
- **asr.py** -- `StubASRProvider`:`default_text`,空串模拟转写失败
|
||
|
||
---
|
||
|
||
## 四、分层测试策略
|
||
|
||
### 4.1 Service unit 测试(unit/features/test_*_service.py)
|
||
|
||
mock repo + fake ports,测编排逻辑和错误分支。每个 feature 预计 10-25 case。
|
||
|
||
```python
|
||
# 示例:展示 service unit test 的边界——mock repo,不碰 DB
|
||
async def test_auth_register_duplicate_phone(fake_sms):
|
||
repo = AsyncMock()
|
||
repo.find_by_phone.return_value = make_user(phone="13800000001")
|
||
|
||
service = AuthService(repo=repo, sms=fake_sms)
|
||
with pytest.raises(ConflictError):
|
||
await service.register(RegisterRequest(phone="13800000001", ...))
|
||
```
|
||
|
||
### 4.2 Service integration 测试(integration/test_*_service_write.py)
|
||
|
||
真实 Postgres(savepoint 隔离)+ 真实 repo + fake ports。**只覆盖关键写路径**的事务边界和 ORM 状态持久化:
|
||
|
||
- **auth register**:User + RefreshToken 在同一事务创建,失败时两者都不残留
|
||
- **payment callback**:订单状态变更 + 用户订阅升级在同一事务
|
||
- **conversation end**:段落状态标记 + Celery 任务提交边界
|
||
|
||
```python
|
||
# 示例:展示与 unit test 的关键区别——验证 DB 中真实存在数据
|
||
@pytest.mark.integration
|
||
async def test_register_creates_user_and_token_atomically(pg_session, fake_sms):
|
||
repo = AuthRepo(pg_session)
|
||
service = AuthService(repo=repo, sms=fake_sms)
|
||
|
||
result = await service.register(RegisterRequest(phone="13800000001", ...))
|
||
|
||
user = await pg_session.get(User, result.user_id)
|
||
assert user is not None
|
||
assert user.phone == "13800000001"
|
||
|
||
tokens = await repo.find_refresh_tokens_by_user(user.id)
|
||
assert len(tokens) == 1
|
||
```
|
||
|
||
每个 feature 写路径预计 3-8 case,总计约 15-25 case。
|
||
|
||
### 4.3 Router unit 测试(unit/features/test_*_router.py)
|
||
|
||
override service stub + get_current_user,只验证 HTTP 契约。与 service 测试不重叠:router 不验证"重复手机号返回什么",只验证"service 抛 ConflictError 时 router 返回 409"。
|
||
|
||
```python
|
||
# 示例:展示 router test 的边界——验证 HTTP 契约,不碰 service 内部
|
||
async def test_register_calls_service(client, auth_service_stub):
|
||
resp = await client.post("/api/auth/register", json={"phone": "13800000001", ...})
|
||
assert resp.status_code == 201
|
||
auth_service_stub.register.assert_called_once()
|
||
|
||
async def test_profile_requires_auth(client):
|
||
resp = await client.get("/api/user/profile")
|
||
assert resp.status_code == 401
|
||
```
|
||
|
||
每个 feature 预计 8-20 case。
|
||
|
||
### 4.4 Repo integration 测试(integration/test_*_repo.py)
|
||
|
||
真实 Postgres(savepoint 隔离),factory helper 构造数据。不用 SQLite。
|
||
|
||
```python
|
||
# 示例:展示 repo test 的边界——真实 Postgres,验证 SQL 正确性
|
||
@pytest.mark.integration
|
||
async def test_find_refresh_token(pg_session):
|
||
user = make_user()
|
||
pg_session.add(user)
|
||
token = make_refresh_token(user_id=user.id, token="abc123")
|
||
pg_session.add(token)
|
||
await pg_session.flush()
|
||
|
||
repo = AuthRepo(pg_session)
|
||
found = await repo.find_refresh_token("abc123")
|
||
assert found is not None
|
||
assert found.user_id == user.id
|
||
```
|
||
|
||
每个 feature 预计 5-15 case。
|
||
|
||
### 4.5 Adapter unit 测试(unit/adapters/)
|
||
|
||
`respx` mock httpx / `unittest.mock.patch` mock SDK client。每个 adapter 至少:成功路径 + 网络超时 + rate limit + 响应格式异常。每个 adapter 预计 8-15 case。
|
||
|
||
### 4.6 WebSocket unit 测试(unit/features/test_ws_*.py)
|
||
|
||
5 个文件对应 ws/ 子包 5 个模块:
|
||
|
||
- **test_ws_connection_manager.py**:注册/注销、并发同 ID、断开容错(6-8 case)
|
||
- **test_ws_message_types.py**:枚举完整性、序列化(5-6 case)
|
||
- **test_ws_pipeline.py**:文本->Agent->落库、音频->ASR->Agent->TTS、无效 token、ASR 失败降级、乱序聚合、幂等去重、结束->整理、重连(15-20 case)
|
||
- **test_ws_profile_collector.py**:缺失检测、提取、已满跳过(5-8 case)
|
||
- **test_ws_quota_guard.py**:配额内通过、耗尽拒绝、plan 差异(5-8 case)
|
||
|
||
### 4.7 Agents unit 测试
|
||
|
||
注入 StubLLMProvider,验证 prompt 构建、响应解析、异常降级。每个 agent 8-12 case。
|
||
|
||
### 4.8 Celery Tasks 测试——两层
|
||
|
||
**Unit**(unit/tasks/test_memoir_tasks.py):mock repo/service 测编排逻辑。20-25 case。
|
||
|
||
**Integration**(integration/test_memoir_tasks_db.py):真实 Postgres + fake ports,验证 DB 状态机持久化。纯 mock 测不到 Chapter 状态转换、segment is_processed 标记、image_assets JSON 持久化。
|
||
|
||
由于 Celery 任务使用同步 Session(不经过 DI),savepoint 隔离不住,改用 truncate 策略(见 `integration/conftest.py` 中的 `sync_session` fixture)。5-10 case,只覆盖关键状态转换。
|
||
|
||
---
|
||
|
||
## 五、端到端流程 + Alembic
|
||
|
||
### 端到端流程(integration/)
|
||
|
||
- **test_auth_flow.py**:注册 -> 登录 -> 获取 profile -> 刷新 -> 登出 -> 旧 token 失效
|
||
- **test_conversation_flow.py**:创建对话 -> 发消息 -> Agent 回复 -> 消息列表 -> 结束 -> 触发整理
|
||
- **test_payment_flow.py**:查看计划 -> 创建订单 -> 模拟回调 -> 验证订阅类型变更
|
||
|
||
### Alembic 测试
|
||
|
||
Forward-only smoke,不做 downgrade(业务 migration 不一定可逆):
|
||
- `test_alembic_upgrade_clean_db`:空库 upgrade head 无报错
|
||
- `test_alembic_current_at_head`:验证测试库 current == head
|
||
|
||
---
|
||
|
||
## 六、CI 集成
|
||
|
||
```yaml
|
||
jobs:
|
||
test:
|
||
name: Run Tests
|
||
runs-on: ubuntu-latest
|
||
services:
|
||
postgres:
|
||
image: postgres:16
|
||
env: { POSTGRES_DB: life_echo_test, POSTGRES_PASSWORD: test }
|
||
ports: ["5432:5432"]
|
||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||
redis:
|
||
image: redis:7
|
||
ports: ["6379:6379"]
|
||
defaults:
|
||
run:
|
||
working-directory: api
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
- uses: astral-sh/setup-uv@v4
|
||
- run: uv sync --dev
|
||
- name: Unit tests
|
||
run: make test-unit
|
||
- name: Integration tests
|
||
run: make test-integration
|
||
env:
|
||
TEST_DATABASE_URL: postgresql+asyncpg://postgres:test@localhost:5432/life_echo_test
|
||
REDIS_URL: redis://localhost:6379/0
|
||
- uses: codecov/codecov-action@v4
|
||
|
||
build-and-push:
|
||
needs: test
|
||
# ... 现有 build 步骤 ...
|
||
```
|
||
|
||
---
|
||
|
||
## 七、测试规则
|
||
|
||
1. **新代码必须附带测试**,PR 不通过无测试的业务变更
|
||
2. **全部使用 pytest 函数 + fixture 风格**
|
||
3. **repo 测试只在 integration/ 中跑 Postgres**,不用 SQLite
|
||
4. **service unit 测试 mock repo + fake ports**;关键写路径补 service integration 测试(真实 Postgres + fake ports)
|
||
5. **router 测试 override service stub + get_current_user**,只验证 HTTP 契约
|
||
6. **adapter 测试 mock 外部调用**,至少成功 + 2 种错误场景
|
||
7. **每个 test function 状态独立**(unit 无 DB;integration 用 savepoint 隔离)
|
||
8. **savepoint 硬性前提:被测代码必须复用注入的 session,不得自行创建**。Celery 任务用独立测试库 + truncate
|
||
9. **integration 标记 `@pytest.mark.integration`**
|
||
10. **默认命令 `make test`**(unit),`make test-all` 全部
|
||
11. **测试目录无 `__init__.py`**
|
||
12. **依赖通过 uv 管理**:`uv sync --dev` 安装,`uv add --dev <pkg>` 新增 dev 依赖,禁止 `pip install`
|
||
13. **类型检查由 `pyright` 负责**:它用于发现静态类型问题,但不能替代 `ruff` 或 `pytest`
|