# 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 ` 添加到 `[project.dependencies]`,dev 依赖通过 `uv add --dev ` 添加到 `[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 ` 新增 dev 依赖,禁止 `pip install` 13. **类型检查由 `pyright` 负责**:它用于发现静态类型问题,但不能替代 `ruff` 或 `pytest`