# 测试指南

本文档定义新 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

```bash
# 运行所有测试
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}`

```python
# ✅ 正确
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**

```python
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 测试完整业务流程

```python
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 实现

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

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

---

## 7. 测试场景覆盖

### 7.1 正常场景（Happy Path）

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

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

### 7.2 边界条件（Boundary Cases）

```python
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）

```python
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）

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

### 7.5 回归场景（Regression）

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

---

## 8. Fixtures

### 8.1 共享 Fixtures

在 `conftest.py` 中定义：

```python
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="这是一个测试项目"
    )
```

使用：

```python
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）

```python
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
```

运行特定标记的测试：

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

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

---

## 10. 断言（Assertions）

### 10.1 基本断言

```python
# 相等
assert result == expected

# 不相等
assert result != unexpected

# 包含
assert item in collection
assert key in dictionary

# 真值
assert condition
assert not condition
```

### 10.2 异常断言

```python
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 近似断言

```python
import pytest

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

---

## 11. 测试数据

### 11.1 避免硬编码 ID

```python
# ❌ 错误
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 使用有意义的测试数据

```python
# ❌ 错误
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 查看覆盖率

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

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

---

## 13. 测试最佳实践

### 13.1 每个测试独立

```python
# ✅ 正确 - 每个测试独立
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 测试一件事

```python
# ❌ 错误 - 测试多件事
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 避免测试实现细节

```python
# ❌ 错误 - 测试实现细节
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）

```yaml
# .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
