videos1.0/featuredoc/tdd_plan.md
Your Name 9cdb99505c 新增 TDD 实施评估与计划文档
featuredoc/tdd_plan.md (V1.0):
- 项目现状诊断:零代码起步,高度适合TDD
- 测试金字塔架构:单元75% + 集成20% + E2E 5%
- 后端测试策略:pytest + TestContainers + 表格驱动测试
- 前端测试策略:Vitest + Testing Library + MSW + Playwright
- AI模型测试策略:标注集验证 + 阈值门禁 + 回归测试
- 11周实施路线图
- 覆盖率目标:后端80%、前端70%、AI模块70%
- 工具链配置与CI/CD集成
- 团队规范与培训计划

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:58:00 +08:00

2180 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TDD 实施评估与计划
| 文档类型 | **Testing Strategy (测试驱动开发实施计划)** |
| --- | --- |
| **项目名称** | SmartAudit (AI 营销内容合规审核平台) |
| **版本号** | V1.0 |
| **发布日期** | 2026-02-02 |
| **关联文档** | tasks.md, DevelopmentPlan.md, FeatureSummary.md |
---
## 版本历史 (Version History)
| 版本 | 日期 | 作者 | 变更说明 |
| --- | --- | --- | --- |
| V1.0 | 2026-02-02 | Claude | 初稿项目诊断、TDD可行性评估、实施计划 |
---
## 目录
1. [项目现状诊断](#1-项目现状诊断)
2. [TDD 可行性评估](#2-tdd-可行性评估)
3. [测试金字塔架构](#3-测试金字塔架构)
4. [后端测试策略](#4-后端测试策略)
5. [前端测试策略](#5-前端测试策略)
6. [AI 模型测试策略](#6-ai-模型测试策略)
7. [端到端测试策略](#7-端到端测试策略)
8. [实施路线图](#8-实施路线图)
9. [测试覆盖率目标](#9-测试覆盖率目标)
10. [工具链配置](#10-工具链配置)
11. [团队规范与培训](#11-团队规范与培训)
12. [风险与挑战](#12-风险与挑战)
---
## 1. 项目现状诊断
### 1.1 代码库状态
| 维度 | 当前状态 | 评估 |
| --- | --- | --- |
| **源代码** | 零代码,纯需求阶段 | ✅ 最佳TDD切入点 |
| **文档完整度** | 5,796行覆盖PRD/RD/技术架构/UI | ✅ 需求明确 |
| **技术选型** | 已确定FastAPI + Next.js | ✅ 测试生态成熟 |
| **任务拆解** | 77个开发任务优先级明确 | ✅ 粒度适合TDD |
| **验收标准** | 每个功能有量化指标 | ✅ 可直接转化为测试用例 |
| **CI/CD** | 已规划,待实施 | ⚠️ 需同步搭建 |
### 1.2 技术栈测试生态评估
| 技术 | 测试框架支持 | 生态成熟度 | Mock/Stub 支持 |
| --- | --- | --- | --- |
| **FastAPI** | pytest + httpx | ⭐⭐⭐⭐⭐ | TestClient 内置 |
| **Celery** | pytest-celery | ⭐⭐⭐⭐ | eager mode 支持 |
| **PostgreSQL** | TestContainers | ⭐⭐⭐⭐⭐ | 容器化隔离 |
| **Next.js/React** | Vitest + RTL | ⭐⭐⭐⭐⭐ | MSW 拦截 |
| **Zustand** | 原生测试支持 | ⭐⭐⭐⭐ | 无需特殊处理 |
| **Socket.io** | jest-socket.io-mock | ⭐⭐⭐ | 需手动 Mock |
### 1.3 项目复杂度分析
```
复杂度热力图:
┌─────────────────────────────────────────────────────────┐
│ 模块 │ 业务复杂度 │ 测试难度 │
├─────────────────────────────────────────────────────────┤
│ 认证与权限 (RBAC) │ ██░░░ │ ██░░░ │
│ Brief 解析 (LLM) │ ████░ │ ████░ │
│ 规则引擎 │ ███░░ │ ██░░░ │
│ 视频上传 (Tus) │ ██░░░ │ ███░░ │
│ ASR/OCR/CV 流水线 │ █████ │ █████ │ ← 最高
│ 多模态时间戳对齐 │ █████ │ █████ │ ← 最高
│ WebSocket 进度推送 │ ██░░░ │ ███░░ │
│ 审核决策流程 │ ███░░ │ ██░░░ │
│ 数据看板 │ ██░░░ │ ██░░░ │
│ 移动端 H5 │ ██░░░ │ ███░░ │
└─────────────────────────────────────────────────────────┘
```
**诊断结论**
-**绿灯项目**零代码起步是实施TDD的理想时机
- ✅ 技术栈测试生态成熟,无明显阻碍
- ⚠️ AI流水线ASR/OCR/CV测试需特殊策略
---
## 2. TDD 可行性评估
### 2.1 综合评分
| 评估维度 | 评分 | 说明 |
| --- | --- | --- |
| **需求明确性** | ⭐⭐⭐⭐⭐ | PRD/RD 详尽,用户故事完整 |
| **功能粒度** | ⭐⭐⭐⭐⭐ | 77个任务边界清晰 |
| **技术可测性** | ⭐⭐⭐⭐ | 主流框架,生态成熟 |
| **团队规模** | ⭐⭐⭐⭐ | 8人精干团队沟通高效 |
| **时间充裕度** | ⭐⭐⭐⭐ | 11周排期非极限压缩 |
| **验收标准量化** | ⭐⭐⭐⭐⭐ | 每个功能有明确KPI |
**总体评估:🟢 高度可行 (95分/100)**
### 2.2 TDD 适用性分析
| 模块类型 | TDD 适用度 | 推荐策略 |
| --- | --- | --- |
| **纯业务逻辑** | ⭐⭐⭐⭐⭐ | 严格 TDD先写测试 |
| **API 接口** | ⭐⭐⭐⭐⭐ | 契约测试 + TDD |
| **数据模型** | ⭐⭐⭐⭐ | TDD + Schema 验证 |
| **规则引擎** | ⭐⭐⭐⭐⭐ | 表格驱动测试 + TDD |
| **AI 模型调用** | ⭐⭐⭐ | 混合模式(输入输出验证) |
| **AI Prompt** | ⭐⭐ | 标注测试集验证 |
| **UI 组件** | ⭐⭐⭐⭐ | 组件级 TDD |
| **E2E 流程** | ⭐⭐ | BDD + E2E 测试 |
### 2.3 TDD 实施模式选择
推荐采用 **"分层混合 TDD"** 模式:
```
┌──────────────────────────────────────────────────────────────┐
│ 分层混合 TDD 模式 │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第1层严格 TDD (100% 覆盖) │ │
│ │ • 工具函数 (utils) │ │
│ │ • 数据验证器 (validators) │ │
│ │ • 规则引擎 (rule engine) │ │
│ │ • 业务逻辑服务 (services) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第2层契约优先 (Contract-First) │ │
│ │ • API 接口 → 先定义 OpenAPI │ │
│ │ • 数据模型 → 先定义 Schema │ │
│ │ • WebSocket 消息 → 先定义消息格式 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第3层标注集验证 (AI 模型) │ │
│ │ • ASR/OCR/CV → 标注测试集 + 阈值验证 │ │
│ │ • LLM Prompt → Few-shot 示例 + 定期回归 │ │
│ │ • 向量检索 → 召回率/精确率评估 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第4层行为驱动 (BDD + E2E) │ │
│ │ • 用户故事 → Playwright E2E │ │
│ │ • 关键路径 → 冒烟测试 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
```
---
## 3. 测试金字塔架构
### 3.1 测试层级分布
```
┌─────────────┐
│ E2E 测试 │ 5%
│ (Playwright)│
└──────┬──────┘
┌──────────┴──────────┐
│ 集成测试 │ 20%
│ (API + DB + 外部) │
└──────────┬──────────┘
┌──────────────────┴──────────────────┐
│ 单元测试 │ 75%
│ (函数、类、组件、纯逻辑) │
└─────────────────────────────────────┘
```
### 3.2 各层级职责划分
| 层级 | 占比 | 覆盖范围 | 执行频率 | 执行时间 |
| --- | --- | --- | --- | --- |
| **单元测试** | 75% | 函数/类/组件/纯逻辑 | 每次提交 | < 30秒 |
| **集成测试** | 20% | API/DB/消息队列/外部服务 | 每次PR | < 5分钟 |
| **E2E 测试** | 5% | 完整用户流程 | 每日/发布前 | < 15分钟 |
### 3.3 SmartAudit 测试分层详情
```
┌────────────────────────────────────────────────────────────────────┐
│ SmartAudit 测试分层 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 【E2E 测试层】 Playwright │
│ ├─ 达人流程:上传视频 → 等待审核 → 查看结果 → 申诉 │
│ ├─ 代理商流程配置Brief → 审核视频 → 驳回/通过 │
│ └─ 品牌方流程:查看看板 → 配置规则 → 审批强制通过 │
│ │
│ 【集成测试层】 pytest + TestContainers │
│ ├─ API 接口测试 (httpx TestClient) │
│ ├─ 数据库集成测试 (PostgreSQL + pgvector) │
│ ├─ Redis 缓存测试 │
│ ├─ Celery 任务测试 (eager mode) │
│ ├─ 文件上传测试 (OSS Mock) │
│ └─ WebSocket 推送测试 │
│ │
│ 【单元测试层】 │
│ │ │
│ │ 后端 (pytest) 前端 (Vitest) │
│ │ ├─ 工具函数 ├─ 工具函数 │
│ │ ├─ 数据验证器 ├─ 格式化函数 │
│ │ ├─ 规则引擎逻辑 ├─ 状态管理 (Zustand) │
│ │ ├─ 时间戳对齐算法 ├─ React Hooks │
│ │ ├─ Brief 解析逻辑 ├─ UI 组件 │
│ │ ├─ 业务服务方法 └─ 表单验证逻辑 │
│ │ └─ Pydantic 模型 │
│ │ │
│ │ AI 模型 (标注测试集) │
│ │ ├─ ASR 输出格式验证 │
│ │ ├─ OCR 输出格式验证 │
│ │ ├─ CV 检测结果验证 │
│ │ ├─ LLM 输出解析验证 │
│ │ └─ 向量相似度计算验证 │
│ │ │
└────────────────────────────────────────────────────────────────────┘
```
---
## 4. 后端测试策略
### 4.1 测试框架选型
| 用途 | 工具 | 说明 |
| --- | --- | --- |
| **测试框架** | pytest | Python 标准插件生态丰富 |
| **异步测试** | pytest-asyncio | FastAPI 异步支持 |
| **覆盖率** | pytest-cov | 覆盖率报告 |
| **Mock** | unittest.mock / pytest-mock | 依赖模拟 |
| **Fixture** | pytest fixtures | 测试数据管理 |
| **参数化** | @pytest.mark.parametrize | 表格驱动测试 |
| **容器化测试** | TestContainers | DB/Redis/MQ 隔离 |
| **API 测试** | httpx + TestClient | FastAPI 内置 |
| **Celery 测试** | celery.contrib.testing | 任务测试 |
| **快照测试** | syrupy | JSON 输出验证 |
### 4.2 目录结构
```
backend/
├── tests/
│ ├── __init__.py
│ ├── conftest.py # 全局 fixtures
│ │
│ ├── unit/ # 单元测试 (75%)
│ │ ├── __init__.py
│ │ ├── test_validators.py # 数据验证器
│ │ ├── test_utils.py # 工具函数
│ │ ├── test_rule_engine.py # 规则引擎
│ │ ├── test_timestamp.py # 时间戳对齐
│ │ ├── test_brief_parser.py # Brief 解析逻辑
│ │ └── services/
│ │ ├── test_auth.py
│ │ ├── test_brief.py
│ │ ├── test_video.py
│ │ └── test_report.py
│ │
│ ├── integration/ # 集成测试 (20%)
│ │ ├── __init__.py
│ │ ├── conftest.py # DB/Redis fixtures
│ │ ├── test_api_auth.py # 认证 API
│ │ ├── test_api_brief.py # Brief API
│ │ ├── test_api_video.py # 视频 API
│ │ ├── test_api_report.py # 报告 API
│ │ ├── test_db_models.py # 数据库模型
│ │ ├── test_celery_tasks.py # 异步任务
│ │ └── test_websocket.py # WebSocket
│ │
│ ├── ai/ # AI 模型测试
│ │ ├── __init__.py
│ │ ├── conftest.py # 测试集加载
│ │ ├── test_asr.py # ASR 输出验证
│ │ ├── test_ocr.py # OCR 输出验证
│ │ ├── test_cv.py # CV 检测验证
│ │ ├── test_llm.py # LLM 输出解析
│ │ └── test_embedding.py # 向量生成验证
│ │
│ ├── e2e/ # 端到端测试 (5%)
│ │ ├── __init__.py
│ │ └── test_workflows.py # 完整流程
│ │
│ └── fixtures/ # 测试数据
│ ├── briefs/ # 测试 Brief 文件
│ ├── videos/ # 测试视频文件
│ ├── rules/ # 测试规则集
│ └── snapshots/ # 快照数据
├── pytest.ini # pytest 配置
└── pyproject.toml # 项目配置
```
### 4.3 核心测试用例设计
#### 4.3.1 规则引擎测试 (表格驱动)
```python
# tests/unit/test_rule_engine.py
import pytest
from app.services.rule_engine import RuleEngine
class TestProhibitedWordDetection:
"""违禁词检测测试 - 表格驱动"""
@pytest.mark.parametrize("text,expected_violations,context", [
# 广告语境下的违禁词 - 应检出
("这是全网销量第一的产品", ["全网第一"], "advertisement"),
("我们是行业领导者", ["行业领导者"], "advertisement"),
("史上最低价促销", ["史上最低价"], "advertisement"),
# 日常语境下的相同词 - 不应检出
("今天是我最开心的一天", [], "daily_conversation"),
("这是我第一次来这里", [], "daily_conversation"),
# 边界情况
("", [], "advertisement"),
("这是一个普通的产品介绍", [], "advertisement"),
# 组合违禁词
("全网销量第一,史上最低价", ["全网第一", "史上最低价"], "advertisement"),
])
def test_prohibited_word_detection(self, text, expected_violations, context):
"""验证违禁词检测的准确性"""
engine = RuleEngine()
result = engine.detect_prohibited_words(text, context=context)
assert set(result.violations) == set(expected_violations)
```
#### 4.3.2 时间戳对齐算法测试
```python
# tests/unit/test_timestamp.py
import pytest
from app.utils.timestamp_align import TimestampAligner
class TestMultiModalAlignment:
"""多模态时间戳对齐测试"""
@pytest.fixture
def aligner(self):
return TimestampAligner(tolerance_ms=500)
@pytest.mark.parametrize("asr_ts,ocr_ts,cv_ts,expected_merged", [
# 完全对齐
(1000, 1000, 1000, 1000),
# 容差范围内对齐
(1000, 1200, 1100, 1100), # 取中位数
# 超出容差
(1000, 2000, 3000, None), # 不合并
])
def test_timestamp_alignment(self, aligner, asr_ts, ocr_ts, cv_ts, expected_merged):
"""验证时间戳对齐逻辑"""
events = [
{"source": "asr", "timestamp_ms": asr_ts, "content": "test"},
{"source": "ocr", "timestamp_ms": ocr_ts, "content": "test"},
{"source": "cv", "timestamp_ms": cv_ts, "content": "logo_detected"},
]
merged = aligner.merge_events(events)
if expected_merged:
assert len(merged) == 1
assert merged[0]["timestamp_ms"] == expected_merged
else:
assert len(merged) == 3 # 未合并
def test_duration_calculation_accuracy(self, aligner):
"""验证时长统计误差 ≤ 0.5秒"""
events = [
{"timestamp_ms": 0, "type": "product_appear"},
{"timestamp_ms": 5500, "type": "product_disappear"},
]
duration = aligner.calculate_duration(events)
# 误差应 ≤ 500ms
assert abs(duration - 5500) <= 500
```
#### 4.3.3 Brief 解析测试
```python
# tests/unit/test_brief_parser.py
import pytest
from app.services.brief_parser import BriefParser
class TestBriefParsing:
"""Brief 解析逻辑测试"""
@pytest.fixture
def parser(self):
return BriefParser()
def test_extract_selling_points(self, parser):
"""验证卖点提取"""
brief_content = """
产品核心卖点:
1. 24小时持妆
2. 天然成分
3. 敏感肌适用
"""
result = parser.extract_selling_points(brief_content)
assert "24小时持妆" in result.selling_points
assert "天然成分" in result.selling_points
assert "敏感肌适用" in result.selling_points
def test_extract_prohibited_words(self, parser):
"""验证禁忌词提取"""
brief_content = """
禁止使用的词汇:
- 药用
- 治疗
- 根治
"""
result = parser.extract_prohibited_words(brief_content)
assert set(result.prohibited_words) == {"药用", "治疗", "根治"}
def test_conflict_detection(self, parser):
"""验证 Brief 与平台规则冲突检测"""
brief_rules = {"allowed_words": ["最佳效果"]}
platform_rules = {"prohibited_words": ["最佳"]}
conflicts = parser.detect_conflicts(brief_rules, platform_rules)
assert len(conflicts) == 1
assert "最佳效果" in conflicts[0]["conflicting_term"]
```
### 4.4 集成测试策略
#### 4.4.1 数据库集成测试
```python
# tests/integration/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(scope="session")
def postgres_container():
"""启动 PostgreSQL 测试容器"""
with PostgresContainer("postgres:15-alpine") as postgres:
yield postgres
@pytest.fixture(scope="function")
def db_session(postgres_container):
"""每个测试函数独立的数据库会话"""
engine = create_engine(postgres_container.get_connection_url())
Session = sessionmaker(bind=engine)
session = Session()
# 创建表
Base.metadata.create_all(engine)
yield session
# 清理
session.rollback()
session.close()
```
#### 4.4.2 API 集成测试
```python
# tests/integration/test_api_brief.py
import pytest
from httpx import AsyncClient
from app.main import app
class TestBriefAPI:
"""Brief API 集成测试"""
@pytest.fixture
async def client(self):
async with AsyncClient(app=app, base_url="http://test") as ac:
yield ac
@pytest.fixture
async def auth_headers(self, client):
"""获取认证头"""
response = await client.post("/auth/login", json={
"username": "test_agency",
"password": "password"
})
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
async def test_upload_brief_pdf(self, client, auth_headers, tmp_path):
"""测试 Brief PDF 上传"""
# 准备测试文件
test_pdf = tmp_path / "test_brief.pdf"
test_pdf.write_bytes(b"%PDF-1.4 test content")
with open(test_pdf, "rb") as f:
response = await client.post(
"/api/v1/briefs/upload",
files={"file": ("test.pdf", f, "application/pdf")},
headers=auth_headers
)
assert response.status_code == 202
assert "task_id" in response.json()
async def test_get_brief_parsing_result(self, client, auth_headers):
"""测试获取 Brief 解析结果"""
# 假设已有解析完成的 Brief
brief_id = "test-brief-id"
response = await client.get(
f"/api/v1/briefs/{brief_id}",
headers=auth_headers
)
assert response.status_code == 200
result = response.json()
assert "selling_points" in result
assert "prohibited_words" in result
```
### 4.5 Celery 异步任务测试
```python
# tests/integration/test_celery_tasks.py
import pytest
from unittest.mock import patch, MagicMock
from app.tasks.video_auditing import audit_video_task
class TestVideoAuditingTask:
"""视频审核异步任务测试"""
@pytest.fixture
def mock_ai_services(self):
"""Mock 所有 AI 服务"""
with patch("app.tasks.video_auditing.ASRService") as mock_asr, \
patch("app.tasks.video_auditing.OCRService") as mock_ocr, \
patch("app.tasks.video_auditing.CVService") as mock_cv:
mock_asr.return_value.transcribe.return_value = {
"text": "这是测试文本",
"timestamps": [{"start": 0, "end": 1000, "text": "这是测试文本"}]
}
mock_ocr.return_value.extract.return_value = {
"frames": [{"timestamp": 500, "text": "字幕内容"}]
}
mock_cv.return_value.detect.return_value = {
"logos": [],
"objects": [{"timestamp": 500, "object": "product"}]
}
yield {"asr": mock_asr, "ocr": mock_ocr, "cv": mock_cv}
def test_video_audit_task_success(self, mock_ai_services, db_session):
"""测试视频审核任务成功执行"""
task_id = "test-task-id"
video_url = "https://test.oss.com/test.mp4"
brief_id = "test-brief-id"
# 使用 eager 模式同步执行
result = audit_video_task.apply(
args=[task_id, video_url, brief_id]
).get()
assert result["status"] == "completed"
assert "report" in result
assert "risk_items" in result["report"]
def test_video_audit_task_with_violations(self, mock_ai_services, db_session):
"""测试检测到违规时的处理"""
# 修改 Mock 返回值,模拟检测到违禁词
mock_ai_services["asr"].return_value.transcribe.return_value = {
"text": "这是全网销量第一的产品",
"timestamps": [{"start": 0, "end": 1000, "text": "这是全网销量第一的产品"}]
}
result = audit_video_task.apply(
args=["test-task", "url", "brief-id"]
).get()
assert result["status"] == "completed"
assert len(result["report"]["risk_items"]) > 0
assert any(
item["type"] == "prohibited_word"
for item in result["report"]["risk_items"]
)
```
---
## 5. 前端测试策略
### 5.1 测试框架选型
| 用途 | 工具 | 说明 |
| --- | --- | --- |
| **单元测试框架** | Vitest | Vite 原生极速执行 |
| **组件测试** | @testing-library/react | 用户行为驱动 |
| **DOM 断言** | @testing-library/jest-dom | 扩展匹配器 |
| **Mock 服务** | MSW (Mock Service Worker) | API 拦截 |
| **E2E 测试** | Playwright | 跨浏览器 |
| **视觉回归** | Percy / Chromatic | 截图对比 |
| **覆盖率** | @vitest/coverage-v8 | 覆盖率报告 |
### 5.2 目录结构
```
frontend/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx # 组件测试
│ │ │ └── Button.stories.tsx # Storybook (可选)
│ │ └── ...
│ │
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useAuth.test.ts # Hook 测试
│ │ └── ...
│ │
│ ├── services/
│ │ ├── api.ts
│ │ ├── api.test.ts # 服务测试
│ │ └── ...
│ │
│ ├── store/
│ │ ├── auth.ts
│ │ ├── auth.test.ts # 状态测试
│ │ └── ...
│ │
│ └── lib/
│ ├── utils.ts
│ ├── utils.test.ts # 工具函数测试
│ └── ...
├── tests/
│ ├── setup.ts # 测试全局配置
│ ├── mocks/
│ │ ├── handlers.ts # MSW 处理器
│ │ └── server.ts # MSW 服务器
│ │
│ ├── integration/ # 集成测试
│ │ ├── BriefUpload.test.tsx
│ │ ├── VideoUpload.test.tsx
│ │ └── ReviewDashboard.test.tsx
│ │
│ └── e2e/ # Playwright E2E
│ ├── creator-flow.spec.ts
│ ├── agency-flow.spec.ts
│ └── brand-flow.spec.ts
├── vitest.config.ts # Vitest 配置
├── playwright.config.ts # Playwright 配置
└── package.json
```
### 5.3 单元测试示例
#### 5.3.1 工具函数测试
```typescript
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import {
formatDuration,
formatTimestamp,
truncateText,
validateVideoFile
} from './utils'
describe('formatDuration', () => {
it('格式化秒数为 mm:ss', () => {
expect(formatDuration(65)).toBe('01:05')
expect(formatDuration(3661)).toBe('61:01')
expect(formatDuration(0)).toBe('00:00')
})
it('处理负数', () => {
expect(formatDuration(-10)).toBe('00:00')
})
})
describe('formatTimestamp', () => {
it('格式化毫秒为 HH:MM:SS.mmm', () => {
expect(formatTimestamp(1500)).toBe('00:00:01.500')
expect(formatTimestamp(3661500)).toBe('01:01:01.500')
})
})
describe('validateVideoFile', () => {
it('接受有效的 MP4 文件', () => {
const file = new File([''], 'test.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'size', { value: 50 * 1024 * 1024 }) // 50MB
const result = validateVideoFile(file)
expect(result.valid).toBe(true)
})
it('拒绝超过 100MB 的文件', () => {
const file = new File([''], 'test.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'size', { value: 150 * 1024 * 1024 }) // 150MB
const result = validateVideoFile(file)
expect(result.valid).toBe(false)
expect(result.error).toContain('100MB')
})
it('拒绝非视频格式', () => {
const file = new File([''], 'test.pdf', { type: 'application/pdf' })
const result = validateVideoFile(file)
expect(result.valid).toBe(false)
expect(result.error).toContain('格式')
})
})
```
#### 5.3.2 React Hook 测试
```typescript
// src/hooks/useAuth.test.ts
import { renderHook, act, waitFor } from '@testing-library/react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuth } from './useAuth'
import { server } from '../tests/mocks/server'
import { rest } from 'msw'
describe('useAuth', () => {
beforeEach(() => {
localStorage.clear()
})
it('初始状态为未登录', () => {
const { result } = renderHook(() => useAuth())
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.user).toBeNull()
})
it('登录成功后更新状态', async () => {
const { result } = renderHook(() => useAuth())
await act(async () => {
await result.current.login('test@example.com', 'password')
})
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true)
expect(result.current.user?.email).toBe('test@example.com')
})
})
it('登录失败时抛出错误', async () => {
// 模拟 API 返回错误
server.use(
rest.post('/api/auth/login', (req, res, ctx) => {
return res(ctx.status(401), ctx.json({ error: '密码错误' }))
})
)
const { result } = renderHook(() => useAuth())
await expect(
act(async () => {
await result.current.login('test@example.com', 'wrong')
})
).rejects.toThrow('密码错误')
})
it('登出后清除状态', async () => {
const { result } = renderHook(() => useAuth())
// 先登录
await act(async () => {
await result.current.login('test@example.com', 'password')
})
// 再登出
act(() => {
result.current.logout()
})
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.user).toBeNull()
})
})
```
#### 5.3.3 Zustand 状态测试
```typescript
// src/store/upload.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useUploadStore } from './upload'
describe('useUploadStore', () => {
beforeEach(() => {
// 重置 store
useUploadStore.setState({
files: [],
uploadProgress: {},
isUploading: false,
})
})
it('添加文件到上传队列', () => {
const file = new File(['test'], 'test.mp4', { type: 'video/mp4' })
useUploadStore.getState().addFile(file)
expect(useUploadStore.getState().files).toHaveLength(1)
expect(useUploadStore.getState().files[0].name).toBe('test.mp4')
})
it('更新上传进度', () => {
const fileId = 'file-123'
useUploadStore.getState().updateProgress(fileId, 50)
expect(useUploadStore.getState().uploadProgress[fileId]).toBe(50)
})
it('移除已完成的文件', () => {
const file = new File(['test'], 'test.mp4', { type: 'video/mp4' })
useUploadStore.getState().addFile(file)
const fileId = useUploadStore.getState().files[0].id
useUploadStore.getState().removeFile(fileId)
expect(useUploadStore.getState().files).toHaveLength(0)
})
})
```
#### 5.3.4 组件测试
```typescript
// src/components/video/VideoUpload.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { VideoUpload } from './VideoUpload'
describe('VideoUpload', () => {
it('渲染上传区域', () => {
render(<VideoUpload onUpload={vi.fn()} />)
expect(screen.getByText(/拖拽视频到此处/)).toBeInTheDocument()
expect(screen.getByText(/支持 MP4、MOV 格式/)).toBeInTheDocument()
})
it('拖拽文件触发上传', async () => {
const onUpload = vi.fn()
render(<VideoUpload onUpload={onUpload} />)
const dropzone = screen.getByTestId('dropzone')
const file = new File(['video content'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: { files: [file] }
})
await waitFor(() => {
expect(onUpload).toHaveBeenCalledWith(file)
})
})
it('拒绝超大文件并显示错误', async () => {
const onUpload = vi.fn()
render(<VideoUpload onUpload={onUpload} maxSize={100 * 1024 * 1024} />)
const file = new File([''], 'large.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'size', { value: 150 * 1024 * 1024 })
const dropzone = screen.getByTestId('dropzone')
fireEvent.drop(dropzone, {
dataTransfer: { files: [file] }
})
await waitFor(() => {
expect(screen.getByText(/文件大小不能超过 100MB/)).toBeInTheDocument()
expect(onUpload).not.toHaveBeenCalled()
})
})
it('显示上传进度', async () => {
render(<VideoUpload onUpload={vi.fn()} initialProgress={45} />)
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '45')
expect(screen.getByText('45%')).toBeInTheDocument()
})
})
```
### 5.4 MSW Mock 服务配置
```typescript
// tests/mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
// 认证 API
rest.post('/api/auth/login', async (req, res, ctx) => {
const { email, password } = await req.json()
if (password === 'password') {
return res(ctx.json({
access_token: 'mock-token',
user: { id: '1', email, role: 'agency' }
}))
}
return res(ctx.status(401), ctx.json({ error: '密码错误' }))
}),
// Brief API
rest.get('/api/v1/briefs/:id', (req, res, ctx) => {
return res(ctx.json({
id: req.params.id,
selling_points: ['24小时持妆', '天然成分'],
prohibited_words: ['药用', '治疗'],
status: 'parsed'
}))
}),
// 视频上传 API
rest.post('/api/v1/videos/upload', async (req, res, ctx) => {
return res(ctx.json({
task_id: 'mock-task-id',
status: 'processing'
}))
}),
// WebSocket 模拟
// 注意MSW 不支持 WebSocket需要单独的 mock
]
```
```typescript
// tests/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
```
```typescript
// tests/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'
import '@testing-library/jest-dom'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
```
### 5.5 前端测试自动化方案总结
| 测试类型 | 工具 | 覆盖范围 | 执行时机 |
| --- | --- | --- | --- |
| **单元测试** | Vitest | 工具函数HooksStore | 每次提交 |
| **组件测试** | Vitest + RTL | UI 组件行为 | 每次提交 |
| **集成测试** | Vitest + MSW | 页面级交互 | 每次 PR |
| **E2E 测试** | Playwright | 完整用户流程 | 每日/发布前 |
| **视觉回归** | Percy/Chromatic | UI 外观变化 | 每次 PR |
| **兼容性测试** | BrowserStack | 跨浏览器/设备 | 发布前 |
---
## 6. AI 模型测试策略
### 6.1 AI 测试的特殊性
AI 模型测试与传统单元测试有本质区别
| 维度 | 传统测试 | AI 模型测试 |
| --- | --- | --- |
| **输出确定性** | 确定性输出 | 概率性输出 |
| **验证方式** | 精确匹配 | 阈值验证 |
| **测试数据** | 少量手工构造 | 大规模标注集 |
| **回归检测** | 断言失败 | 指标下降 |
| **维护成本** | | 需持续更新 |
### 6.2 AI 测试分层
```
┌────────────────────────────────────────────────────────────────┐
│ AI 模型测试分层 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 【第1层接口契约测试】 │
│ • 输入格式验证 │
│ • 输出结构验证 │
│ • 错误处理验证 │
│ → 使用 pytest + JSON Schema 验证 │
│ │
│ 【第2层功能正确性测试】 │
│ • 标注测试集验证 │
│ • 边界情况覆盖 │
│ • 阈值达标检查 │
│ → 使用标注数据 + 指标计算 │
│ │
│ 【第3层回归测试】 │
│ • 模型更新后的指标对比 │
│ • Prompt 修改后的行为验证 │
│ • 新增 Case 的持续覆盖 │
│ → 使用 MLflow + 版本对比 │
│ │
│ 【第4层对抗测试】 │
│ • 边缘输入 (长文本、特殊字符、空输入) │
│ • 对抗样本 (刻意绕过检测) │
│ • 压力测试 (高并发、大文件) │
│ → 使用 fuzzing + 人工设计 │
│ │
└────────────────────────────────────────────────────────────────┘
```
### 6.3 各 AI 模块测试策略
#### 6.3.1 ASR 语音识别测试
```python
# tests/ai/test_asr.py
import pytest
from app.ai.asr import ASRService
class TestASRService:
"""ASR 语音识别测试"""
@pytest.fixture
def asr(self):
return ASRService()
@pytest.fixture
def test_audio_samples(self):
"""加载标注测试集"""
return load_labeled_dataset("tests/fixtures/asr_samples/")
def test_output_format(self, asr):
"""验证输出格式契约"""
result = asr.transcribe("tests/fixtures/sample.wav")
# 验证必需字段
assert "text" in result
assert "timestamps" in result
assert isinstance(result["timestamps"], list)
# 验证时间戳格式
for ts in result["timestamps"]:
assert "start" in ts
assert "end" in ts
assert "text" in ts
assert ts["end"] >= ts["start"]
def test_word_error_rate(self, asr, test_audio_samples):
"""验证字错率 ≤ 10%"""
total_errors = 0
total_words = 0
for sample in test_audio_samples:
result = asr.transcribe(sample["audio_path"])
wer = calculate_wer(result["text"], sample["ground_truth"])
total_errors += wer * len(sample["ground_truth"].split())
total_words += len(sample["ground_truth"].split())
overall_wer = total_errors / total_words
assert overall_wer <= 0.10, f"WER {overall_wer:.2%} 超过阈值 10%"
def test_timestamp_accuracy(self, asr, test_audio_samples):
"""验证时间戳准确性"""
for sample in test_audio_samples:
result = asr.transcribe(sample["audio_path"])
# 验证起止时间与音频时长匹配
audio_duration = get_audio_duration(sample["audio_path"])
last_timestamp = result["timestamps"][-1]["end"]
# 允许 500ms 误差
assert abs(last_timestamp - audio_duration * 1000) <= 500
```
#### 6.3.2 违禁词检测测试
```python
# tests/ai/test_prohibited_words.py
import pytest
from app.ai.nlp import ProhibitedWordDetector
class TestProhibitedWordDetector:
"""违禁词检测测试"""
@pytest.fixture
def detector(self):
return ProhibitedWordDetector()
@pytest.fixture
def labeled_dataset(self):
"""
标注数据集格式:
{
"text": "这是全网销量第一的产品",
"context": "advertisement",
"expected_violations": ["全网第一"],
"should_block": true
}
"""
return load_labeled_dataset("tests/fixtures/prohibited_words/")
def test_recall_rate(self, detector, labeled_dataset):
"""验证召回率 ≥ 95%"""
true_positives = 0
false_negatives = 0
for sample in labeled_dataset:
if not sample["expected_violations"]:
continue
result = detector.detect(sample["text"], sample["context"])
detected = set(result.violations)
expected = set(sample["expected_violations"])
true_positives += len(detected & expected)
false_negatives += len(expected - detected)
recall = true_positives / (true_positives + false_negatives)
assert recall >= 0.95, f"召回率 {recall:.2%} 低于阈值 95%"
def test_false_positive_rate(self, detector, labeled_dataset):
"""验证误报率 ≤ 5%"""
false_positives = 0
true_negatives = 0
# 只测试不应有违规的样本
negative_samples = [
s for s in labeled_dataset
if not s["expected_violations"]
]
for sample in negative_samples:
result = detector.detect(sample["text"], sample["context"])
if result.violations:
false_positives += 1
else:
true_negatives += 1
fpr = false_positives / (false_positives + true_negatives)
assert fpr <= 0.05, f"误报率 {fpr:.2%} 超过阈值 5%"
def test_context_awareness(self, detector):
"""验证语境感知能力"""
text = "这是我最开心的一天"
# 广告语境 - 不应误报
result_ad = detector.detect(text, context="advertisement")
assert len(result_ad.violations) == 0, "日常用语在广告语境误报"
# 日常语境 - 不应误报
result_daily = detector.detect(text, context="daily_conversation")
assert len(result_daily.violations) == 0, "日常用语误报"
```
#### 6.3.3 Logo 向量检索测试
```python
# tests/ai/test_logo_detection.py
import pytest
from app.ai.cv import LogoDetector
class TestLogoDetector:
"""Logo 检测测试"""
@pytest.fixture
def detector(self):
return LogoDetector()
@pytest.fixture
def logo_test_set(self):
"""
测试集包含:
- 200+ 竞品 Logo 图片
- 各种遮挡、模糊、旋转场景
- 负样本(无 Logo 的图片)
"""
return load_labeled_dataset("tests/fixtures/logos/")
def test_f1_score(self, detector, logo_test_set):
"""验证 F1 ≥ 0.85"""
predictions = []
ground_truths = []
for sample in logo_test_set:
result = detector.detect(sample["image_path"])
predictions.append(result.detected_logos)
ground_truths.append(sample["ground_truth_logos"])
f1 = calculate_f1(predictions, ground_truths)
assert f1 >= 0.85, f"F1 {f1:.2f} 低于阈值 0.85"
def test_partial_occlusion(self, detector, logo_test_set):
"""验证 30% 遮挡场景下的检测能力"""
occluded_samples = [
s for s in logo_test_set
if s.get("occlusion_rate", 0) >= 0.3
]
correct = 0
for sample in occluded_samples:
result = detector.detect(sample["image_path"])
if sample["ground_truth_logos"] == result.detected_logos:
correct += 1
accuracy = correct / len(occluded_samples)
# 遮挡场景允许稍低的准确率
assert accuracy >= 0.75, f"遮挡场景准确率 {accuracy:.2%} 过低"
def test_new_logo_instant_detection(self, detector):
"""验证新 Logo 上传后即刻生效"""
# 上传新 Logo
new_logo_path = "tests/fixtures/new_competitor_logo.png"
detector.add_logo(new_logo_path, brand="New Competitor")
# 立即测试检测
test_frame = "tests/fixtures/frame_with_new_logo.jpg"
result = detector.detect(test_frame)
assert "New Competitor" in result.detected_logos
```
### 6.4 LLM Prompt 测试
```python
# tests/ai/test_llm.py
import pytest
from app.ai.llm import LLMService
class TestLLMPrompts:
"""LLM Prompt 测试"""
@pytest.fixture
def llm(self):
return LLMService()
@pytest.fixture
def few_shot_examples(self):
"""Few-shot 示例集"""
return load_few_shot_examples("tests/fixtures/llm_examples/")
def test_brief_parsing_output_format(self, llm):
"""验证 Brief 解析输出格式"""
brief_content = """
产品卖点24小时持妆
禁止使用:药用、治疗
"""
result = llm.parse_brief(brief_content)
# 验证输出结构
assert "selling_points" in result
assert "prohibited_words" in result
assert isinstance(result["selling_points"], list)
assert isinstance(result["prohibited_words"], list)
def test_context_understanding(self, llm, few_shot_examples):
"""验证语境理解能力"""
context_examples = [
e for e in few_shot_examples
if e["type"] == "context_understanding"
]
correct = 0
for example in context_examples:
result = llm.classify_context(example["text"])
if result["context"] == example["expected_context"]:
correct += 1
accuracy = correct / len(context_examples)
assert accuracy >= 0.90, f"语境理解准确率 {accuracy:.2%} 过低"
def test_sentiment_analysis(self, llm):
"""验证舆情风险检测"""
test_cases = [
{"text": "这个产品太油腻了", "expected_risk": "greasy"},
{"text": "正常的产品介绍", "expected_risk": None},
{"text": "男人就该这样", "expected_risk": "gender_bias"},
]
for case in test_cases:
result = llm.analyze_sentiment(case["text"])
if case["expected_risk"]:
assert result.risk_type == case["expected_risk"]
else:
assert result.risk_type is None
```
### 6.5 AI 测试数据集管理
```
tests/fixtures/
├── asr_samples/ # ASR 测试集
│ ├── manifest.json # 数据清单
│ ├── audio/
│ │ ├── sample_001.wav
│ │ └── ...
│ └── transcripts/
│ ├── sample_001.json # 标注结果
│ └── ...
├── prohibited_words/ # 违禁词测试集
│ ├── positive_samples.json # 应检出样本
│ ├── negative_samples.json # 不应检出样本
│ └── context_samples.json # 语境测试样本
├── logos/ # Logo 测试集
│ ├── manifest.json
│ ├── images/
│ │ ├── logo_001.jpg
│ │ └── ...
│ └── annotations/
│ ├── logo_001.json
│ └── ...
├── llm_examples/ # LLM 测试集
│ ├── brief_parsing.json
│ ├── context_understanding.json
│ └── sentiment_analysis.json
└── README.md # 测试集说明文档
```
---
## 7. 端到端测试策略
### 7.1 E2E 测试框架
| 工具 | 用途 |
| --- | --- |
| **Playwright** | 跨浏览器 E2E 测试 |
| **@playwright/test** | 测试运行器 |
| **playwright-report** | 测试报告 |
| **BrowserStack** | 真机云测试 |
### 7.2 核心用户流程测试
```typescript
// tests/e2e/creator-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('达人端完整流程', () => {
test.beforeEach(async ({ page }) => {
// 登录达人账号
await page.goto('/auth/login')
await page.fill('[name="email"]', 'creator@test.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/creator/tasks')
})
test('上传视频 → 等待审核 → 查看结果', async ({ page }) => {
// 1. 进入上传页面
await page.click('text=上传')
await expect(page).toHaveURL('/creator/upload')
// 2. 上传视频
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles('tests/fixtures/test_video.mp4')
// 3. 等待上传完成
await expect(page.locator('.upload-progress')).toHaveText(/100%/)
// 4. 等待审核完成(可能需要等待)
await page.click('button:has-text("提交审核")')
// 5. 验证进入审核中状态
await expect(page.locator('.audit-status')).toHaveText(/审核中/)
// 6. 等待审核完成(最多 5 分钟)
await expect(page.locator('.audit-status')).toHaveText(
/已通过|需修改/,
{ timeout: 300000 }
)
// 7. 验证结果页面
await page.click('text=查看结果')
await expect(page.locator('.result-banner')).toBeVisible()
})
test('申诉流程', async ({ page }) => {
// 假设有一个需修改的任务
await page.goto('/creator/tasks?status=needs_revision')
await page.click('.task-card >> nth=0')
// 1. 点击申诉按钮
await page.click('button:has-text("申诉")')
// 2. 填写申诉理由
await page.fill('textarea[name="reason"]', '这不是广告用语,是日常表达')
// 3. 提交申诉
await page.click('button:has-text("提交申诉")')
// 4. 验证申诉成功
await expect(page.locator('.toast')).toHaveText(/申诉已提交/)
})
})
```
```typescript
// tests/e2e/agency-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('代理商端完整流程', () => {
test.beforeEach(async ({ page }) => {
// 登录代理商账号
await page.goto('/auth/login')
await page.fill('[name="email"]', 'agency@test.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/agency/dashboard')
})
test('配置 Brief → 审核视频 → 通过', async ({ page }) => {
// 1. 上传 Brief
await page.click('text=Brief 管理')
await page.click('button:has-text("上传 Brief")')
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles('tests/fixtures/test_brief.pdf')
// 2. 等待解析完成
await expect(page.locator('.parsing-status')).toHaveText(/解析完成/, {
timeout: 60000
})
// 3. 确认规则
await page.click('button:has-text("确认规则")')
// 4. 进入审核台
await page.click('text=审核台')
await page.click('.pending-task >> nth=0')
// 5. 查看视频和检查单
await expect(page.locator('.video-player')).toBeVisible()
await expect(page.locator('.checklist')).toBeVisible()
// 6. 通过审核
await page.click('button:has-text("通过")')
await page.click('button:has-text("确认")')
// 7. 验证状态更新
await expect(page.locator('.task-status')).toHaveText(/已通过/)
})
test('驳回视频', async ({ page }) => {
await page.goto('/agency/review')
await page.click('.pending-task >> nth=0')
// 勾选问题
await page.check('input[name="issue_0"]')
await page.check('input[name="issue_1"]')
// 驳回
await page.click('button:has-text("驳回")')
await page.click('button:has-text("确认")')
await expect(page.locator('.task-status')).toHaveText(/已驳回/)
})
})
```
### 7.3 移动端 E2E 测试
```typescript
// tests/e2e/mobile-creator.spec.ts
import { test, expect, devices } from '@playwright/test'
test.use({
...devices['iPhone 13'],
})
test.describe('达人端 H5 移动端测试', () => {
test('移动端上传视频', async ({ page }) => {
await page.goto('/creator/upload')
// 验证移动端布局
await expect(page.locator('.bottom-nav')).toBeVisible()
// 验证防锁屏提示
await expect(page.locator('.wakelock-hint')).toBeVisible()
// 模拟上传
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles('tests/fixtures/test_video.mp4')
// 验证进度显示
await expect(page.locator('.circular-progress')).toBeVisible()
})
})
```
### 7.4 Playwright 配置
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 60000,
retries: 2,
workers: 4,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'test-results/junit.xml' }],
],
use: {
baseURL: process.env.TEST_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
// 桌面浏览器
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
// 移动端
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
// 本地开发服务器
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
```
---
## 8. 实施路线图
### 8.1 分阶段实施计划
```
┌──────────────────────────────────────────────────────────────────────┐
│ TDD 实施路线图 (11 周) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Phase 0: 基础建设 (Week 0, 并行进行) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ • 搭建 pytest/Vitest 测试框架 │ │
│ │ • 配置 CI/CD 测试流水线 │ │
│ │ • 建立代码覆盖率门禁 │ │
│ │ • 准备 AI 测试数据集 (初始 100+ 样本) │ │
│ │ • 团队 TDD 培训 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 1: 基础设施 + Brief 引擎 (Week 1-2) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 后端 TDD (100% 覆盖): │ │
│ │ • 数据模型测试 → 数据模型实现 │ │
│ │ • 验证器测试 → 验证器实现 │ │
│ │ • 规则引擎测试 → 规则引擎实现 │ │
│ │ • Brief 解析测试 → Brief 解析实现 │ │
│ │ │ │
│ │ 前端 TDD (基础组件): │ │
│ │ • 工具函数测试 → 工具函数实现 │ │
│ │ • 基础组件测试 → 基础组件实现 │ │
│ │ │ │
│ │ API Mock 服务搭建 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 2: 核心 AI 流水线 (Week 3-6) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ AI 模型测试 (标注集验证): │ │
│ │ • 建立 ASR/OCR/CV 测试集 (≥500 样本) │ │
│ │ • 接口契约测试 → AI 服务封装 │ │
│ │ • 阈值验证测试 → 模型调优 │ │
│ │ │ │
│ │ 核心算法 TDD: │ │
│ │ • 时间戳对齐测试 → 对齐算法实现 │ │
│ │ • 多模态融合测试 → 融合逻辑实现 │ │
│ │ │ │
│ │ 集成测试: │ │
│ │ • Celery 任务测试 │ │
│ │ • WebSocket 推送测试 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 3: 界面开发 (Week 7-9) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 前端组件 TDD: │ │
│ │ • 组件测试 → 组件实现 │ │
│ │ • Hook 测试 → Hook 实现 │ │
│ │ • Store 测试 → Store 实现 │ │
│ │ │ │
│ │ 页面集成测试: │ │
│ │ • MSW Mock + 页面交互测试 │ │
│ │ │ │
│ │ E2E 测试骨架: │ │
│ │ • 核心用户流程 E2E (Playwright) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ Phase 4: 联调与验收 (Week 10-11) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 测试完善: │ │
│ │ • E2E 测试补全 │ │
│ │ • 性能测试 (Locust) │ │
│ │ • 兼容性测试 (BrowserStack) │ │
│ │ │ │
│ │ AI 模型验收: │ │
│ │ • 完整测试集运行 │ │
│ │ • 指标达标验证 │ │
│ │ │ │
│ │ 回归测试: │ │
│ │ • 全量回归 │ │
│ │ • 冒烟测试自动化 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
```
### 8.2 每周测试交付物
| 周次 | 测试交付物 | 覆盖率目标 |
| --- | --- | --- |
| Week 1 | 后端框架测试数据模型测试 | 后端 80% |
| Week 2 | Brief 解析测试规则引擎测试 | 后端 80% |
| Week 3 | ASR/OCR 接口测试 | AI 模块 60% |
| Week 4 | CV 检测测试向量检索测试 | AI 模块 70% |
| Week 5 | 时间戳对齐测试多模态融合测试 | AI 模块 80% |
| Week 6 | Celery 任务测试WebSocket 测试 | 后端 85% |
| Week 7 | 前端工具函数测试Hook 测试 | 前端 60% |
| Week 8 | 前端组件测试Store 测试 | 前端 70% |
| Week 9 | 页面集成测试E2E 骨架 | 前端 75% |
| Week 10 | E2E 补全性能测试 | E2E 核心路径 100% |
| Week 11 | 兼容性测试全量回归 | 整体 75% |
---
## 9. 测试覆盖率目标
### 9.1 覆盖率门禁
| 层级 | 目标覆盖率 | 门禁策略 |
| --- | --- | --- |
| **后端单元测试** | 80% | PR 阻断 |
| **前端单元测试** | 70% | PR 阻断 |
| **AI 模块测试** | 70% | PR 阻断 |
| **集成测试** | 60% | PR 警告 |
| **E2E 测试** | 核心路径 100% | 发布阻断 |
### 9.2 覆盖率例外
以下代码可豁免覆盖率要求
| 代码类型 | 原因 |
| --- | --- |
| 第三方 SDK 封装 | 信任上游 |
| 环境配置代码 | 运行时验证 |
| 日志/监控代码 | 非核心逻辑 |
| 迁移脚本 | 一次性执行 |
### 9.3 覆盖率报告
```yaml
# .github/workflows/test.yml (覆盖率报告部分)
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage.xml
fail_ci_if_error: true
- name: Coverage Gate
run: |
COVERAGE=$(cat coverage/coverage.txt | grep "TOTAL" | awk '{print $4}' | tr -d '%')
if [ "$COVERAGE" -lt "75" ]; then
echo "Coverage $COVERAGE% is below threshold 75%"
exit 1
fi
```
---
## 10. 工具链配置
### 10.1 后端工具链
```toml
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
"--strict-markers",
"-ra",
"--cov=app",
"--cov-report=xml",
"--cov-report=html",
"--cov-fail-under=75",
]
asyncio_mode = "auto"
markers = [
"slow: 标记慢速测试",
"integration: 集成测试",
"ai: AI 模型测试",
]
[tool.coverage.run]
branch = true
source = ["app"]
omit = [
"*/migrations/*",
"*/tests/*",
"*/__init__.py",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if TYPE_CHECKING:",
]
```
### 10.2 前端工具链
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
],
thresholds: {
lines: 70,
functions: 70,
branches: 70,
statements: 70,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
```
### 10.3 CI/CD 配置
```yaml
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
cd backend
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Run linting
run: |
cd backend
ruff check .
mypy app
- name: Run tests
run: |
cd backend
pytest --cov --cov-report=xml
env:
DATABASE_URL: postgresql://postgres:test@localhost/test
REDIS_URL: redis://localhost:6379
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: backend/coverage.xml
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: |
cd frontend
npm ci
- name: Run linting
run: |
cd frontend
npm run lint
- name: Run tests
run: |
cd frontend
npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: frontend/coverage/coverage-final.json
e2e-test:
runs-on: ubuntu-latest
needs: [backend-test, frontend-test]
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Playwright
run: |
cd frontend
npm ci
npx playwright install --with-deps
- name: Run E2E tests
run: |
cd frontend
npm run test:e2e
- name: Upload Playwright report
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: frontend/playwright-report/
```
---
## 11. 团队规范与培训
### 11.1 TDD 工作流规范
```
┌────────────────────────────────────────────────────────────────────┐
│ TDD 红-绿-重构循环 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ 🔴 RED │ 1. 编写一个失败的测试 │
│ │ (失败) │ • 测试必须能运行 │
│ │ │ • 测试必须失败 │
│ └──────┬──────┘ • 失败原因是功能未实现 │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 🟢 GREEN │ 2. 编写最少的代码让测试通过 │
│ │ (通过) │ • 不要过度设计 │
│ │ │ • 只写足够通过测试的代码 │
│ └──────┬──────┘ • 可以"作弊"(硬编码) │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 🔄 REFACTOR │ 3. 重构代码 │
│ │ (重构) │ • 移除重复 │
│ │ │ • 改善设计 │
│ └──────┬──────┘ • 测试仍然通过 │
│ │ │
│ └──────────────────────────────────────────────────────► │
│ 循环 │
│ │
└────────────────────────────────────────────────────────────────────┘
```
### 11.2 测试命名规范
```python
# Python (pytest)
class TestCalculator:
def test_add_two_positive_numbers_returns_sum(self):
"""测试两个正数相加返回正确的和"""
pass
def test_divide_by_zero_raises_error(self):
"""测试除以零抛出错误"""
pass
```
```typescript
// TypeScript (Vitest)
describe('Calculator', () => {
it('should return sum when adding two positive numbers', () => {
// ...
})
it('should throw error when dividing by zero', () => {
// ...
})
})
```
### 11.3 测试文件组织规范
| 规则 | 说明 |
| --- | --- |
| 测试文件与源文件同目录 | `utils.ts` `utils.test.ts` |
| 测试目录 `__tests__` | 复杂模块可用目录 |
| 命名后缀 `.test.ts` / `_test.py` | 便于识别和自动发现 |
| 每个测试文件只测一个模块 | 职责单一 |
### 11.4 团队培训计划
| 阶段 | 时长 | 内容 | 交付物 |
| --- | --- | --- | --- |
| **TDD 基础** | 2h | TDD 概念红绿重构循环 | 培训 PPT |
| **pytest 实战** | 2h | pytest 使用fixture参数化 | 示例代码 |
| **Vitest 实战** | 2h | Vitest 使用RTLMSW | 示例代码 |
| **AI 测试** | 2h | 标注集管理阈值验证 | 测试模板 |
| **代码审查** | 持续 | PR 中检查测试质量 | 审查清单 |
### 11.5 代码审查清单
```markdown
## PR 测试审查清单
### 必须项
- [ ] 新功能有对应的单元测试
- [ ] 测试覆盖了正常路径和异常路径
- [ ] 测试命名清晰,描述预期行为
- [ ] 测试独立运行,不依赖执行顺序
- [ ] 覆盖率不低于门禁阈值
### 建议项
- [ ] 使用参数化测试覆盖多种输入
- [ ] Mock 外部依赖,避免测试不稳定
- [ ] 测试执行时间 < 1秒单元测试
- [ ] 无硬编码的测试数据使用 fixture
### AI 模块特别项
- [ ] 有对应的标注测试集
- [ ] 验证了输出格式
- [ ] 验证了阈值指标
```
---
## 12. 风险与挑战
### 12.1 风险矩阵
| 风险 | 可能性 | 影响 | 缓解措施 |
| --- | --- | --- | --- |
| **AI 模型幻觉** | 🔴 | 🔴 | 完整标注集 + 人工抽查 + 持续监控 |
| **测试数据不足** | 🟡 | 🔴 | 持续收集真实数据 + 数据增强 |
| **E2E 测试不稳定** | 🟡 | 🟡 | 重试机制 + 等待策略优化 |
| **团队 TDD 经验不足** | 🟡 | 🟡 | 培训 + 结对编程 + 代码审查 |
| **测试维护成本高** | 🟡 | 🟡 | 测试重构 + 共享 fixture |
| **CI/CD 执行慢** | 🟢 | 🟡 | 并行执行 + 增量测试 |
### 12.2 AI 测试特殊挑战
| 挑战 | 应对策略 |
| --- | --- |
| **LLM 输出不确定性** | 验证结构而非精确内容 + 多次采样取共识 |
| **Prompt 变更影响大** | 建立 Prompt 版本管理 + 回归测试 |
| **标注成本高** | 优先覆盖高风险场景 + 主动学习采样 |
| **模型更新回归** | 建立基线 + 自动化指标对比 |
| **边缘情况难穷尽** | 对抗样本生成 + 持续收集 badcase |
### 12.3 TDD 常见误区
| 误区 | 正确做法 |
| --- | --- |
| 先写代码再补测试 | 严格遵循红-绿-重构 |
| 追求 100% 覆盖率 | 关注有意义的测试 |
| 测试实现细节 | 测试行为和结果 |
| 过度 Mock | Mock 真正的外部依赖 |
| 测试代码不维护 | 测试代码同样需要重构 |
---
## 13. 附录
### 13.1 相关文档
| 文档 | 说明 |
| --- | --- |
| tasks.md | 开发任务清单 |
| DevelopmentPlan.md | 技术架构与开发计划 |
| FeatureSummary.md | 功能清单与验收标准 |
| User_Role_Interfaces.md | 用户角色与界面规范 |
### 13.2 参考资源
| 资源 | 链接 |
| --- | --- |
| pytest 官方文档 | https://docs.pytest.org/ |
| Vitest 官方文档 | https://vitest.dev/ |
| Testing Library | https://testing-library.com/ |
| Playwright 官方文档 | https://playwright.dev/ |
| MSW 官方文档 | https://mswjs.io/ |
| TDD by Example (书籍) | Kent Beck |
### 13.3 术语表
| 术语 | 定义 |
| --- | --- |
| **TDD** | Test-Driven Development测试驱动开发 |
| **BDD** | Behavior-Driven Development行为驱动开发 |
| **SUT** | System Under Test被测系统 |
| **Fixture** | 测试固定装置用于准备测试环境 |
| **Mock** | 模拟对象替代真实依赖 |
| **Stub** | 存根返回预设值的简化实现 |
| **Coverage** | 代码覆盖率 |
| **Regression** | 回归测试 |
---
## 14. 总结
### 14.1 核心结论
1. **SmartAudit 项目高度适合实施 TDD**
- 零代码起步是最佳切入点
- 需求明确验收标准量化
- 技术栈测试生态成熟
2. **采用分层混合 TDD 策略**
- 业务逻辑严格 TDD
- AI 模型标注集验证
- E2EBDD + 自动化
3. **前端测试自动化方案**
- 单元测试Vitest + Testing Library
- 组件测试RTL + MSW
- E2E 测试Playwright
- 视觉回归Percy/Chromatic
4. **关键成功因素**
- 测试框架在 Week 0 搭建完成
- CI/CD 门禁从第一行代码开始
- AI 测试集持续积累
- 团队培训与规范执行
### 14.2 下一步行动
| 优先级 | 行动项 | 负责人 | 时间 |
| --- | --- | --- | --- |
| P0 | 创建 backend/tests/ 目录结构 | Backend Lead | Week 0 |
| P0 | 配置 pytest + CI/CD | Backend Lead | Week 0 |
| P0 | 创建 frontend/tests/ 目录结构 | Frontend Lead | Week 0 |
| P0 | 配置 Vitest + Playwright | Frontend Lead | Week 0 |
| P0 | 团队 TDD 培训 | Tech Lead | Week 0 |
| P1 | 建立 AI 测试数据集框架 | AI Engineer | Week 1 |
| P1 | 编写核心模块测试规范文档 | Tech Lead | Week 1 |
---
**文档状态**:✅ 完成
**下次审阅**开发启动后 2