Files
xiaoxia-saas/docs/TESTING-GUIDE.md
T
Xiaoxia AI 6152d49d18 docs: add coding/API/testing standards
- CODING-STANDARD.md: PEP 8, type hints, Clean Architecture constraints, security
- API-SPEC.md: RESTful design, HTTP methods, status codes, request/response format
- TESTING-GUIDE.md: test strategy, AAA pattern, fixtures, coverage targets
- complete examples included
2026-06-15 16:20:11 +08:00

11 KiB
Raw Blame History

测试指南

本文档定义新 SaaS 项目的测试策略、测试规范和最佳实践。


1. 测试策略

1.1 测试金字塔

        E2E Tests (少量)
          /        \
     Integration Tests (中量)
        /              \
   Unit Tests (大量,按需)

当前阶段(Phase 1

  • 集成测试优先 - 覆盖完整业务流程
  • 单元测试辅助 - 覆盖复杂逻辑
  • E2E 测试占位 - Phase 2-3 补充

原因

  • 集成测试验证架构正确性
  • 集成测试覆盖核心业务流程
  • In-Memory 实现使得集成测试成本低

2. 测试工具

测试框架: pytest
测试环境: in-memory 或 SQLite
覆盖率: pytest-cov

# 运行所有测试
pytest tests/integration/ -v

# 运行指定测试
pytest tests/integration/test_projects.py -v

# 运行带覆盖率
pytest --cov=packages --cov=apps --cov-report=html

# 运行快速测试(跳过慢速测试)
pytest -m "not slow"

3. 测试结构

tests/
├── conftest.py               # 共享 fixtures
├── integration/              # 集成测试
│   ├── test_projects.py
│   ├── test_ingest_pipeline.py
│   ├── test_classification_pipeline.py
│   └── test_upload_pipeline.py
├── unit/                     # 单元测试
│   ├── test_project_entity.py
│   └── test_asset_validation.py
└── e2e/                      # 端到端测试(占位)
    └── README.md

4. 测试命名

4.1 测试文件

test_{module_name}.py

4.2 测试函数

推荐格式test_{what}_{condition}

# ✅ 正确
def test_create_project_with_valid_name():
    pass

def test_create_project_with_empty_name_should_fail():
    pass

def test_list_projects_by_workspace():
    pass

def test_ingest_asset_updates_job_status_to_completed():
    pass

# ❌ 错误
def test1():
    pass

def test_project():
    pass

def test_stuff():
    pass

5. 测试结构(AAA 模式)

Arrange - Act - Assert

def test_create_project():
    # Arrange(准备)
    workspace_id = "ws-1"
    name = "测试项目"
    repository = InMemoryProjectRepository()
    use_case = CreateProjectUseCase(repository)
    
    # Act(执行)
    project = use_case.execute(
        CreateProjectCommand(workspace_id=workspace_id, name=name)
    )
    
    # Assert(断言)
    assert project.name == name
    assert project.workspace_id == workspace_id
    assert project.id != ""

6. 集成测试

6.1 测试完整业务流程

def test_upload_to_asset_full_pipeline():
    """测试完整上传链路:上传 → storage → 入库任务 → worker → asset 创建。"""
    # Arrange
    job_repo = InMemoryIngestJobRepository()
    asset_repo = InMemoryAssetRepository()
    
    # Act - 提交入库任务
    use_case = SubmitIngestJobUseCase(job_repo)
    job = use_case.execute(
        SubmitIngestJobCommand(
            workspace_id="ws-1",
            project_id="proj-1",
            library_id="lib-1",
            storage_key="uploads/abc123/video.mp4",
        )
    )
    
    # Act - 模拟 worker 处理
    result = simulate_ingest_asset(job.id, job_repo, asset_repo)
    
    # Assert - 验证任务完成
    assert result["status"] == "completed"
    updated_job = job_repo.get(job.id)
    assert updated_job.status == IngestJobStatus.COMPLETED
    
    # Assert - 验证 asset 创建
    assets = asset_repo.list_by_library("lib-1")
    assert len(assets) == 1
    assert assets[0].storage_key == "uploads/abc123/video.mp4"

6.2 使用 In-Memory 实现

from packages.adapters.in_memory import (
    InMemoryProjectRepository,
    InMemoryAssetRepository,
    InMemoryIngestJobRepository,
)

def test_something():
    # 使用 in-memory 实现,快速且无外部依赖
    repository = InMemoryProjectRepository()
    # ...

7. 测试场景覆盖

7.1 正常场景(Happy Path

def test_create_project_with_valid_data():
    """测试创建项目(正常场景)。"""
    pass

def test_list_projects_returns_all_projects():
    """测试查询项目列表(正常场景)。"""
    pass

7.2 边界条件(Boundary Cases

def test_create_project_with_empty_name_should_fail():
    """测试创建项目(项目名为空应失败)。"""
    with pytest.raises(ValueError, match="项目名称不能为空"):
        Project.create(workspace_id="ws-1", name="")

def test_create_project_with_max_length_name():
    """测试创建项目(项目名最大长度)。"""
    long_name = "a" * 100
    project = Project.create(workspace_id="ws-1", name=long_name)
    assert len(project.name) == 100

def test_create_project_with_too_long_name_should_fail():
    """测试创建项目(项目名超长应失败)。"""
    too_long_name = "a" * 101
    with pytest.raises(ValueError, match="项目名称不能超过"):
        Project.create(workspace_id="ws-1", name=too_long_name)

7.3 异常场景(Error Cases

def test_get_nonexistent_project_returns_none():
    """测试查询不存在的项目(应返回 None)。"""
    repository = InMemoryProjectRepository()
    project = repository.get("nonexistent-id")
    assert project is None

def test_update_nonexistent_project_should_fail():
    """测试更新不存在的项目(应失败)。"""
    repository = InMemoryProjectRepository()
    with pytest.raises(ValueError, match="项目.*不存在"):
        repository.update(Project(id="nonexistent-id", ...))

7.4 并发场景(Concurrency

def test_concurrent_create_same_project():
    """测试并发创建相同项目(应处理冲突)。"""
    # Phase 2 补充
    pass

7.5 回归场景(Regression

def test_regression_ingest_job_status_not_reset():
    """回归测试:验证 IngestJob 状态不会被意外重置(bug #123)。"""
    pass

8. Fixtures

8.1 共享 Fixtures

conftest.py 中定义:

import pytest
from packages.adapters.in_memory import InMemoryProjectRepository

@pytest.fixture
def project_repository():
    """项目仓储 fixture。"""
    return InMemoryProjectRepository()

@pytest.fixture
def sample_project():
    """示例项目 fixture。"""
    return Project.create(
        workspace_id="ws-1",
        name="测试项目",
        description="这是一个测试项目"
    )

使用:

def test_create_project(project_repository):
    """测试创建项目。"""
    project = Project.create(workspace_id="ws-1", name="新项目")
    saved = project_repository.create(project)
    assert saved.id == project.id

def test_list_projects(project_repository, sample_project):
    """测试查询项目列表。"""
    project_repository.create(sample_project)
    projects = project_repository.list_by_workspace("ws-1")
    assert len(projects) == 1

9. 测试标记(Markers

import pytest

@pytest.mark.slow
def test_large_dataset():
    """慢速测试(大数据集)。"""
    pass

@pytest.mark.integration
def test_full_pipeline():
    """集成测试。"""
    pass

@pytest.mark.unit
def test_entity_validation():
    """单元测试。"""
    pass

运行特定标记的测试:

# 只运行集成测试
pytest -m integration

# 跳过慢速测试
pytest -m "not slow"

10. 断言(Assertions

10.1 基本断言

# 相等
assert result == expected

# 不相等
assert result != unexpected

# 包含
assert item in collection
assert key in dictionary

# 真值
assert condition
assert not condition

10.2 异常断言

import pytest

# 验证抛出异常
with pytest.raises(ValueError):
    Project.create(workspace_id="ws-1", name="")

# 验证异常消息
with pytest.raises(ValueError, match="项目名称不能为空"):
    Project.create(workspace_id="ws-1", name="")

10.3 近似断言

import pytest

# 浮点数近似相等
assert result == pytest.approx(0.85, rel=1e-2)

11. 测试数据

11.1 避免硬编码 ID

# ❌ 错误
def test_create_project():
    project = Project(id="proj-123", ...)

# ✅ 正确
def test_create_project():
    project = Project.create(workspace_id="ws-1", name="测试项目")
    assert project.id != ""  # ID 由系统生成

11.2 使用有意义的测试数据

# ❌ 错误
def test_create_project():
    project = Project.create(workspace_id="a", name="b")

# ✅ 正确
def test_create_project():
    project = Project.create(
        workspace_id="ws-test-001",
        name="电商平台项目",
        description="2026 年 Q2 电商平台重构项目"
    )

12. 测试覆盖率

12.1 目标覆盖率

  • Domain 层: 100%
  • Application 层: 90%+
  • Adapters 层: 80%+
  • API 层: 70%+

12.2 查看覆盖率

# 生成 HTML 报告
pytest --cov=packages --cov=apps --cov-report=html

# 打开报告
open htmlcov/index.html  # macOS/Linux
start htmlcov/index.html  # Windows

13. 测试最佳实践

13.1 每个测试独立

# ✅ 正确 - 每个测试独立
def test_create_project():
    repository = InMemoryProjectRepository()
    project = Project.create(workspace_id="ws-1", name="项目 1")
    repository.create(project)

def test_list_projects():
    repository = InMemoryProjectRepository()  # 新实例
    project = Project.create(workspace_id="ws-1", name="项目 2")
    repository.create(project)
    projects = repository.list_by_workspace("ws-1")
    assert len(projects) == 1

13.2 测试一件事

# ❌ 错误 - 测试多件事
def test_project_crud():
    repository = InMemoryProjectRepository()
    # 创建
    project = Project.create(...)
    repository.create(project)
    # 查询
    found = repository.get(project.id)
    # 更新
    found.name = "新名称"
    repository.update(found)
    # 删除
    repository.delete(project.id)

# ✅ 正确 - 拆分成多个测试
def test_create_project():
    pass

def test_get_project():
    pass

def test_update_project():
    pass

def test_delete_project():
    pass

13.3 避免测试实现细节

# ❌ 错误 - 测试实现细节
def test_project_repository_uses_dict():
    repository = InMemoryProjectRepository()
    assert isinstance(repository._items, dict)

# ✅ 正确 - 测试行为
def test_project_repository_stores_project():
    repository = InMemoryProjectRepository()
    project = Project.create(workspace_id="ws-1", name="项目")
    repository.create(project)
    
    found = repository.get(project.id)
    assert found is not None
    assert found.name == "项目"

14. 持续集成(Phase 2

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: '3.12'
      - run: pip install -r requirements.txt
      - run: pytest tests/ --cov=packages --cov=apps

最后更新: 2026-06-15
版本: v1.0