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>
2180 lines
78 KiB
Markdown
2180 lines
78 KiB
Markdown
# 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 | 工具函数、Hooks、Store | 每次提交 |
|
||
| **组件测试** | 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 使用、RTL、MSW | 示例代码 |
|
||
| **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 模型:标注集验证
|
||
- E2E:BDD + 自动化
|
||
|
||
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 周
|