Files
life-echo/docs/plans/backend-test-system.md

21 KiB
Raw Blame History

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 安装全部依赖(含 devuv.lock 纳入版本控制。不在 addopts 中全局过滤 marker。

当前开发工具链职责约定:

  • ruff:负责 lint + format
  • pytest:统一测试入口
  • pytest-asyncio:负责 async def 测试和异步 fixture
  • pytest-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.pyunit 测试用)

关键设计router 测试 override service(不是 portservice 测试直接构造实例注入 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 portsservice 层单测用)----
# 每个 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 测试用 appoverride 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.pysavepoint 隔离)

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-boySQLAlchemyModelFactory 默认为同步 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 -- InMemorySmsSendersent: list 记录历史,should_fail 可切换失败模式
  • llm.py -- StubLLMProviderdefault_responseresponses: list(按序消耗),call_count
  • storage.py -- InMemoryStoragedict[str, bytes] 后端
  • image_gen.py -- StubImageGeneratorgenerate() 返回预设结果,check_status() 按调用次数切换
  • tts.py -- StubTTSProvider:返回固定 bytes
  • asr.py -- StubASRProviderdefault_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

真实 Postgressavepoint 隔离)+ 真实 repo + fake ports。只覆盖关键写路径的事务边界和 ORM 状态持久化:

  • auth registerUser + 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

真实 Postgressavepoint 隔离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 测试——两层

Unitunit/tasks/test_memoir_tasks.pymock repo/service 测编排逻辑。20-25 case。

Integrationintegration/test_memoir_tasks_db.py真实 Postgres + fake ports验证 DB 状态机持久化。纯 mock 测不到 Chapter 状态转换、segment is_processed 标记、image_assets JSON 持久化。

由于 Celery 任务使用同步 Session不经过 DIsavepoint 隔离不住,改用 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 步骤 ...

七、测试规则

  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 无 DBintegration 用 savepoint 隔离)
  8. savepoint 硬性前提:被测代码必须复用注入的 session不得自行创建。Celery 任务用独立测试库 + truncate
  9. integration 标记 @pytest.mark.integration
  10. 默认命令 make testunitmake test-all 全部
  11. 测试目录无 __init__.py
  12. 依赖通过 uv 管理uv sync --dev 安装,uv add --dev <pkg> 新增 dev 依赖,禁止 pip install
  13. 类型检查由 pyright 负责:它用于发现静态类型问题,但不能替代 ruffpytest