21 KiB
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.pytest_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+formatpytest:统一测试入口pytest-asyncio:负责async def测试和异步 fixturepytest-cov:负责覆盖率统计pyright:负责类型检查,但不替代测试和lint
3.2 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。两者不重叠。
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 隔离)
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 函数:
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。
# 示例:展示 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 任务提交边界
# 示例:展示与 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"。
# 示例:展示 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。
# 示例:展示 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 集成
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 步骤 ...
七、测试规则
- 新代码必须附带测试,PR 不通过无测试的业务变更
- 全部使用 pytest 函数 + fixture 风格
- repo 测试只在 integration/ 中跑 Postgres,不用 SQLite
- service unit 测试 mock repo + fake ports;关键写路径补 service integration 测试(真实 Postgres + fake ports)
- router 测试 override service stub + get_current_user,只验证 HTTP 契约
- adapter 测试 mock 外部调用,至少成功 + 2 种错误场景
- 每个 test function 状态独立(unit 无 DB;integration 用 savepoint 隔离)
- savepoint 硬性前提:被测代码必须复用注入的 session,不得自行创建。Celery 任务用独立测试库 + truncate
- integration 标记
@pytest.mark.integration - 默认命令
make test(unit),make test-all全部 - 测试目录无
__init__.py - 依赖通过 uv 管理:
uv sync --dev安装,uv add --dev <pkg>新增 dev 依赖,禁止pip install - 类型检查由
pyright负责:它用于发现静态类型问题,但不能替代ruff或pytest