版本: v1.0
作者: 测试负责人
日期: 2026-05-19
状态: 正式发布
| 维度 | 现状 | 评分 |
|---|---|---|
| 单元测试(后端) | ❌ 完全缺失 | 0/10 |
| 单元测试(前端) | ❌ 完全缺失 | 0/10 |
| 集成测试(API) | ⚠️ 有但不规范,断言过宽 | 3/10 |
| E2E 测试(UI) | ⚠️ 有但流于表面,只测页面存在 | 4/10 |
| 测试基础设施 | ✅ Playwright setup/teardown 设计良好 | 7/10 |
| CI/CD 集成 | ⚠️ 不完善,依赖环境变量 | 4/10 |
| 综合评分 | 3.0/10 |
| 风险等级 | 风险项 | 说明 |
|---|---|---|
| 🔴 致命 | LangGraph 书籍生成无测试 | 97KB 的大控制器,12 个 AI 节点,0 测试覆盖 |
| 🔴 致命 | 计费/订阅系统无测试 | 涉及金额计算,错误 = 直接经济损失 |
| 🔴 致命 | TTS Provider 熔断器无测试 | 生产环境 API 故障时熔断逻辑是否正确? |
| 🟡 高危 | 支付回调无测试 | 支付宝/微信支付回调逻辑无验证 |
| 🟡 高危 | 音频配额扣除无测试 | 按分钟计费,精度问题可能导致少扣或多扣 |
| 🟢 中等 | 前端表单验证无测试 | 用户输入错误缺少保护 |
| 🟢 中等 | Prisma 数据操作无测试 | ORM 模型变更可能破坏查询 |
┌─────────────────┐
│ E2E 测试 (5%) │ 10 条关键用户旅程
│ Playwright │ 完整业务流程验证
┌┴─────────────────┴┐
│ 集成测试 (20%) │ 40+ 条 API 流程测试
│ Vitest + Supertest│ 模块间交互 + 数据库
┌┴───────────────────┴┐
│ 组件测试 (15%) │ 30+ 条 Vue 组件测试
│ Vitest + vue-test │ 表单/交互/状态管理
┌┴─────────────────────┴┐
│ 单元测试 (60%) │ 200+ 条纯函数/类测试
│ Vitest │ 核心逻辑全覆盖
└┴───────────────────────┘
| 层级 | 工具 | 运行时间 | 运行频率 | 覆盖率目标 |
|---|---|---|---|---|
| L0 - 单元测试 | Vitest | <30s | 每次 git push | 行 80% / 分支 70% |
| L1 - 组件测试 | Vitest + @vue/test-utils | <60s | 每次 git push | 关键组件 90% |
| L2 - 集成测试 | Vitest + Supertest | <120s | 每次 PR | 关键 API 80% |
| L3 - API E2E | Playwright APIRequest | <180s | 每次 PR | 所有端点 |
| L4 - UI E2E | Playwright Browser | <300s | 每次发版 / 每日 | 10条关键旅程 |
tests/
├── unit/ # L0 单元测试
│ ├── server/ # 后端单元测试(镜像 src/modules/ 结构)
│ │ ├── common/
│ │ │ ├── audio-calculator.test.ts
│ │ │ └── quote-checker.test.ts
│ │ ├── tts/
│ │ │ ├── provider-registry.test.ts
│ │ │ ├── circuit-breaker.test.ts
│ │ │ └── aliyun-provider.test.ts
│ │ ├── subscription/
│ │ │ ├── quota-checker.test.ts
│ │ │ └── plan-calculator.test.ts
│ │ ├── payment/
│ │ │ └── order-service.test.ts
│ │ ├── book-generator/
│ │ │ ├── state-machine.test.ts
│ │ │ ├── work-nodes.test.ts
│ │ │ └── audio-merger.test.ts
│ │ ├── player/
│ │ │ └── progress-service.test.ts
│ │ └── auth/
│ │ └── jwt-service.test.ts
│ └── frontend/ # 前端单元测试
│ ├── utils/
│ │ ├── request.test.ts
│ │ └── format.test.ts
│ └── stores/
│ ├── tts-store.test.ts
│ └── user-store.test.ts
│
├── component/ # L1 组件测试
│ ├── player/
│ │ └── AudioPlayer.test.ts
│ ├── book-generator/
│ │ ├── CreateForm.test.ts
│ │ └── ChapterList.test.ts
│ └── common/
│ ├── PlayButton.test.ts
│ └── AudioCard.test.ts
│
├── integration/ # L2 集成测试
│ ├── api/
│ │ ├── auth-flow.test.ts
│ │ ├── book-generator-flow.test.ts
│ │ ├── tts-flow.test.ts
│ │ ├── payment-flow.test.ts
│ │ ├── subscription-flow.test.ts
│ │ └── publish-flow.test.ts
│ └── db/
│ ├── prisma-crud.test.ts
│ └── transaction.test.ts
│
├── e2e/ # L3-L4 E2E 测试
│ ├── api/ # L3 API E2E
│ │ ├── health.spec.ts # 保留旧文件(改进版)
│ │ └── api-crud.spec.ts # CRUD 完整测试
│ ├── ui/ # L4 UI E2E
│ │ ├── critical-journeys/ # 关键旅程(新增)
│ │ │ ├── journey-01-register-login.spec.ts
│ │ │ ├── journey-02-free-tts.spec.ts
│ │ │ ├── journey-03-book-generate.spec.ts
│ │ │ ├── journey-04-subscribe-play.spec.ts
│ │ │ └── journey-05-publish.spec.ts
│ │ └── smoke/ # 保持旧结构
│ │ ├── health.spec.ts
│ │ └── pages-load.spec.ts
│ └── regression/ # 保留旧结构
│ ├── 01-auth.spec.ts
│ ├── 02-tts.spec.ts
│ └── ...
│
└── helpers/ # 测试辅助
├── fixtures/ # 测试夹具
│ ├── user-fixture.ts
│ ├── book-fixture.ts
│ └── audio-fixture.ts
├── mocks/ # Mock 工厂
│ ├── llm-mock.ts
│ ├── tts-mock.ts
│ └── payment-mock.ts
└── db-helper.ts # 数据库测试助手
影响 × 复杂度 = 测试优先级
高影响 │ P2 │ P1(先测)│
│ 认证JWT │ LangGraph │
│ 搜索过滤 │ 计费/订阅 │
│ 播放进度 │ TTS熔断器 │
────────┼───────────┼─────────────┤
低影响 │ P4 │ P3 │
│ BGM列表 │ 视频合成 │
│ 模板库 │ 音频合并 │
└───────────┴─────────────┘
低复杂度 高复杂度
P1(第一批,本周):计费/订阅 → TTS熔断器 → LangGraph 状态机
P2(第二批,下周):认证JWT → 搜索 → 播放进度
P3(第三批):视频合成 → 音频合并 → 支付
P4(第四批):BGM → 模板 → 分类
// tests/unit/server/subscription/quota-checker.test.ts
describe('QuotaChecker', () => {
describe('checkAudioQuota', () => {
// 正常场景
it('免费用户 10分钟内 应通过', () => {});
it('专业版用户 100分钟内 应通过', () => {});
it('超额后按单价计费', () => {});
// 边界值
it('刚好等于配额上限', () => {});
it('超出配额 1 秒', () => {});
it('配额用尽后再生成', () => {});
// 异常场景
it('用户不存在时抛出 UserNotFoundError', () => {});
it('配额已用尽且不支持超额', () => {});
it('并发扣除配额不超扣', () => {});
// 时间边界
it('跨月重置配额', () => {});
it('会员到期后降级为免费配额', () => {});
});
describe('calculateCost', () => {
it('免费套餐不收费', () => {});
it('字数 ÷ 语速 = 分钟数(四舍五入)', () => {});
it('超额部分按单价计算', () => {});
it('计算结果精确到分', () => {});
});
});
// tests/unit/server/tts/circuit-breaker.test.ts
describe('TTS CircuitBreaker', () => {
describe('状态转换', () => {
it('初始状态为 CLOSED', () => {});
it('连续失败 3 次 → OPEN', () => {});
it('OPEN 状态拒绝所有请求', () => {});
it('冷却 60 秒后 → HALF_OPEN', () => {});
it('HALF_OPEN 成功 → CLOSED', () => {});
it('HALF_OPEN 失败 → 回到 OPEN', () => {});
});
describe('Provider 轮转', () => {
it('主 Provider 故障时自动切换', () => {});
it('所有 Provider 都故障时抛出 AllProvidersDownError', () => {});
it('轮转顺序公平', () => {});
});
describe('健康检查', () => {
it('定时检查 OPEN 状态 Provider', () => {});
it('健康检查失败重置冷却时间', () => {});
});
});
// tests/unit/server/book-generator/state-machine.test.ts
describe('BookGenerator StateGraph', () => {
describe('状态流转', () => {
it('INIT → DEEP_PLAN → RICH_OUTLINE → CONTENT', () => {});
it('CONTENT → CONTINUITY_EDIT → QUALITY_CHECK', () => {});
it('QUALITY_CHECK 不通过 → REWRITE(最多3次)', () => {});
it('第 4 次 REWRITE 仍失败 → 标记失败', () => {});
});
describe('并行写作节点', () => {
it('多个章节并行生成', () => {});
it('并行节点全部完成后才进入下一步', () => {});
it('某章节失败不影响其他章节', () => {});
});
describe('状态持久化', () => {
it('中断后可从检查点恢复', () => {});
it('恢复后从正确节点继续', () => {});
});
describe('异常处理', () => {
it('LLM API 超时时重试', () => {});
it('生成内容为空时重新生成', () => {});
it('用户取消时优雅终止', () => {});
});
});
// tests/helpers/mocks/llm-mock.ts
/**
* LLM Mock 工厂
* 所有单元测试中禁止调用真实 AI API
*/
export function createLLMMock() {
return {
invoke: vi.fn().mockResolvedValue({
content: JSON.stringify({
title: '测试书籍标题',
outline: [
{ chapter: 1, title: '第一章', summary: '章节摘要' }
]
})
}),
stream: vi.fn().mockImplementation(async function* () {
yield { content: '模拟流式输出' };
})
};
}
/**
* TTS Mock 工厂
*/
export function createTTSMock() {
return {
synthesize: vi.fn().mockResolvedValue({
audioUrl: 'https://mock-cdn.example.com/audio/test.mp3',
duration: 120, // 秒
size: 2048000 // 字节
})
};
}
/**
* 支付 Mock 工厂
*/
export function createPaymentMock() {
return {
createOrder: vi.fn().mockResolvedValue({
orderNo: 'MOCK202605190001',
qrCode: 'https://mock-pay.example.com/qr/xxx'
}),
verifyCallback: vi.fn().mockResolvedValue({ verified: true })
};
}
// tests/integration/db/prisma-crud.test.ts
describe('Prisma CRUD 集成测试', () => {
let db: PrismaClient;
beforeAll(async () => {
db = new PrismaClient({
datasources: { db: { url: process.env.TEST_DATABASE_URL } }
});
await db.$connect();
});
afterAll(async () => {
await db.$disconnect();
});
// 每个测试包裹在事务中,测试完回滚
beforeEach(async () => {
await db.$executeRawUnsafe('BEGIN');
});
afterEach(async () => {
await db.$executeRawUnsafe('ROLLBACK');
});
describe('User CRUD', () => {
it('创建用户并查询', async () => {});
it('更新用户会员等级', async () => {});
it('软删除用户', async () => {});
it('唯一索引冲突 - 重复手机号', async () => {});
});
describe('Book + Chapter 关联查询', () => {
it('创建书籍时级联创建章节', async () => {});
it('查询书籍时包含所有章节', async () => {});
it('删除书籍时级联删除章节', async () => {});
});
describe('事务', () => {
it('配额扣除+音频创建应在同一事务', async () => {});
it('事务失败时全部回滚', async () => {});
it('并发事务不互相干扰', async () => {});
});
});
// tests/integration/api/book-generator-flow.test.ts
describe('书籍生成完整流程集成测试', () => {
let app: Koa;
let authToken: string;
let testUserId: number;
beforeAll(async () => {
// 启动测试服务器(使用测试数据库)
app = await createTestApp();
// 创建测试用户并获取 token
const { token, userId } = await createTestUser(app);
authToken = token;
testUserId = userId;
});
describe('完整生成流程', () => {
it('Step 1: 预估 - POST /api/book-generator/langgraph/estimate', async () => {
const res = await request(app.callback())
.post('/api/book-generator/langgraph/estimate')
.set('Authorization', `Bearer ${authToken}`)
.send({ topic: '人工智能简史', wordCount: 50000 });
expect(res.status).toBe(200);
expect(res.body.data.estimatedCost).toBeGreaterThan(0);
expect(res.body.data.estimatedDuration).toBeGreaterThan(0);
});
it('Step 2: 检测类型 - POST /api/book-generator/langgraph/detect-book-type', async () => {
const res = await request(app.callback())
.post('/api/book-generator/langgraph/detect-book-type')
.set('Authorization', `Bearer ${authToken}`)
.send({ topic: '人工智能简史' });
expect(res.status).toBe(200);
expect(['技术', '科普', '学术']).toContain(res.body.data.bookType);
});
it('Step 3: 创建 - POST /api/book-generator/langgraph/create', async () => {
const res = await request(app.callback())
.post('/api/book-generator/langgraph/create')
.set('Authorization', `Bearer ${authToken}`)
.send({
title: '测试书籍',
topic: '人工智能简史',
wordCount: 50000,
strategy: 'deep-plan-parallel'
});
expect(res.status).toBe(200);
expect(res.body.data.bookId).toBeDefined();
});
it('配额不足时应返回错误', async () => {
// 模拟配额耗尽
await exhaustQuota(testUserId);
const res = await request(app.callback())
.post('/api/book-generator/langgraph/create')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '超出配额的书籍', topic: '测试', wordCount: 100000 });
expect(res.status).toBe(402);
expect(res.body.error).toMatch(/配额不足/);
});
});
});
// tests/integration/api/payment-flow.test.ts
describe('支付流程集成测试', () => {
describe('创建订单', () => {
it('支付宝扫码支付 - 创建订单成功', async () => {});
it('微信支付 - 创建订单成功', async () => {});
it('无效套餐类型返回400', async () => {});
it('重复下单(幂等性检查)', async () => {});
});
describe('支付回调', () => {
it('支付宝回调 - 验签通过 → 更新会员', async () => {});
it('支付宝回调 - 验签失败 → 拒绝', async () => {});
it('微信回调 - 解密成功 → 更新会员', async () => {});
it('重复回调(幂等处理)', async () => {});
});
describe('会员状态', () => {
it('付费后 memberLevel 正确更新', async () => {});
it('memberExpireAt 正确计算(月付+30天)', async () => {});
it('到期后自动降级为免费', async () => {});
});
});
游客访问首页 → 点击注册 → 输入手机号 → 获取验证码 → 注册成功
→ 自动登录 → 跳转首页 → 看到音频列表 → 看到底部TabBar
验证点:
登录用户 → 点击"创建" → 输入文本 → 选择音色
→ 点击"生成语音" → 等待合成完成 → 列表出现新音频
→ 点击播放 → 音频播放 → 进度条前进 → 封面旋转
验证点:
登录用户 → 进入书籍生成 → 输入书名和主题 → 选择生成策略
→ 点击"开始生成" → 看到进度(第1步:规划 → 第2步:大纲 → 第3步:写作 → 第4步:编辑)
→ 生成完成 → 查看章节列表 → 点击某章节 → 查看内容 → 播放章节音频
验证点:
免费用户 → 尝试超额生成 → 提示配额不足 → 跳转会员页
→ 选择专业版套餐 → 确认支付 → 显示支付宝二维码
→ 模拟支付成功 → 会员等级更新 → 返回生成 → 配额已提升
验证点:
用户 → 我的页面 → 选择一个已生成的音频 → 点击"公开"
→ 确认公开 → 返回首页 → 刷新 → 该音频出现在列表中
→ 其他用户(未登录)访问首页 → 也能看到该音频
验证点:
// tests/e2e/ui/critical-journeys/journey-03-book-generate.spec.ts
import { test, expect } from '@playwright/test';
import { createTestUser, loginAs } from '../../helpers/auth-helper';
test.describe('Journey 3: 书籍生成完整流程', () => {
let user: { phone: string; token: string };
test.beforeAll(async ({ browser }) => {
user = await createTestUser(browser);
});
test('J03-01: 正常生成流程', async ({ page }) => {
// === Arrange ===
await loginAs(page, user);
await page.goto('/pages/book-generator/index');
// === Act: 进入创建页 ===
await page.click('[data-testid="create-book-btn"]');
await expect(page).toHaveURL(/book-generator\/create/);
// === Act: 填写表单 ===
await page.fill('[data-testid="book-title-input"]', '测试书籍:AI 的过去与未来');
await page.fill('[data-testid="book-topic-input"]', '人工智能发展史');
await page.fill('[data-testid="word-count-input"]', '30000');
await page.selectOption('[data-testid="strategy-select"]', 'deep-plan-parallel');
// === Act: 点击预估 ===
await page.click('[data-testid="estimate-btn"]');
await expect(page.locator('[data-testid="estimate-result"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('[data-testid="estimated-cost"]')).toContainText(/分钟/);
// === Act: 开始生成 ===
await page.click('[data-testid="start-generate-btn"]');
// === Assert: 进度展示 ===
await expect(page.locator('[data-testid="progress-stage"]')).toContainText('深度规划', { timeout: 30000 });
await expect(page.locator('[data-testid="progress-stage"]')).toContainText('生成大纲', { timeout: 60000 });
await expect(page.locator('[data-testid="progress-stage"]')).toContainText('写作中', { timeout: 120000 });
// === Assert: 生成完成 ===
await expect(page.locator('[data-testid="generation-complete"]')).toBeVisible({ timeout: 180000 });
await expect(page.locator('[data-testid="chapter-count"]')).toContainText(/\d+ 章/);
// === Assert: 跳转详情 ===
await page.click('[data-testid="view-detail-btn"]');
await expect(page).toHaveURL(/book-generator\/detail/);
await expect(page.locator('[data-testid="chapter-list"] .chapter-item').first()).toBeVisible();
// === Assert: 章节内容 ===
await page.locator('[data-testid="chapter-list"] .chapter-item').first().click();
await expect(page.locator('[data-testid="chapter-content"]')).not.toBeEmpty();
await expect(page.locator('[data-testid="chapter-audio-player"]')).toBeVisible();
});
test('J03-02: 配额不足时正确提示', async ({ page }) => {
// 预先耗尽配额
await exhaustUserQuota(user.token);
await loginAs(page, user);
await page.goto('/pages/book-generator/create');
await page.fill('[data-testid="book-title-input"]', '超配额测试');
await page.click('[data-testid="start-generate-btn"]');
// 应提示配额不足
await expect(page.locator('[data-testid="quota-error-dialog"]')).toBeVisible();
await expect(page.locator('[data-testid="quota-error-dialog"]')).toContainText('配额不足');
// 应提供升级入口
await expect(page.locator('[data-testid="upgrade-member-btn"]')).toBeVisible();
});
});
| 旧文件 | 问题 | 改造方案 |
|---|---|---|
regression/01-14 |
断言 expect([0,401,404]).toContain(res.code) |
拆分场景,明确每个用例的预期状态码 |
regression/23-frontend-e2e.spec.ts |
32个用例只测页面存在 | 拆分为各模块组件测试,保留关键旅程 |
6个 test.skip() 用例 |
功能已实现但测试没更新 | 修复或删除 skip |
smoke/api-readonly.spec.ts |
30个端点只有GET | 补充 POST/PUT/DELETE |
| 缺失 14-16, 18-22 | 功能已实现 | 按优先级补全 |
生产环境:MySQL (localhost:3306) → audio_book 库
测试环境:MySQL (localhost:3307) → audio_book_test 库(独立端口)
策略:
1. 每个测试文件的事务隔离(BEGIN/ROLLBACK)
2. 集成测试使用真实 MySQL,非 SQLite
3. 测试前自动运行 Prisma migrate
4. 测试后自动清理测试数据
// tests/vitest.config.ts (新建)
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 单元测试
include: ['tests/unit/**/*.test.ts'],
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['server/src/modules/**/*.ts', 'my-uniapp-vue3/src/**/*.ts'],
exclude: ['**/*.d.ts', '**/node_modules/**'],
thresholds: {
lines: 80,
branches: 70,
functions: 80,
statements: 80,
// 按模块设置不同阈值
'server/src/modules/subscription/**': { lines: 95, branches: 90 },
'server/src/modules/book-generator/**': { lines: 75, branches: 65 },
'server/src/modules/tts/**': { lines: 90, branches: 85 },
}
}
}
});
// tests/helpers/db-helper.ts (新建)
import { PrismaClient } from '@prisma/client';
/**
* 创建测试专用数据库连接
*/
export function createTestDb(): PrismaClient {
return new PrismaClient({
datasources: {
db: { url: process.env.TEST_DATABASE_URL || 'mysql://root:@localhost:3307/audio_book_test' }
}
});
}
/**
* 清理测试数据
*/
export async function cleanTestData(db: PrismaClient) {
const tablenames = await db.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename FROM pg_tables WHERE schemaname='public'
`;
// 按外键依赖顺序删除
const deleteOrder = [
'AlbumAudio', 'PlayRecord', 'Favorite', 'Comment', 'Notification',
'BookChapter', 'Book', 'Audio', 'Album', 'VideoMaterial', 'VideoProject',
'Order', 'User'
];
for (const table of deleteOrder) {
await db.$executeRawUnsafe(`DELETE FROM \`${table}\``);
}
}
/**
* 测试数据工厂
*/
export class TestDataFactory {
static async createUser(db: PrismaClient, overrides = {}) {
return db.user.create({
data: {
phone: `138${String(Math.random()).slice(2, 10)}`,
memberLevel: 0,
dailyUsage: 0,
...overrides
}
});
}
static async createBook(db: PrismaClient, userId: number, overrides = {}) {
return db.book.create({
data: {
userId,
title: `测试书籍 ${Date.now()}`,
description: '测试描述',
totalChapters: 5,
estimatedWords: 25000,
status: 'completed',
...overrides
}
});
}
static async createAudio(db: PrismaClient, userId: number, overrides = {}) {
return db.audio.create({
data: {
userId,
title: `测试音频 ${Date.now()}`,
text: '这是测试文本内容',
audioUrl: 'https://mock-cdn.example.com/audio/test.mp3',
audioDuration: 120,
wordCount: 300,
voiceId: 'zh_female_qingxin',
status: 'completed',
isPublic: false,
...overrides
}
});
}
}
测试报告产出物:
1. 覆盖率报告 (coverage/index.html)
- 行/分支/函数/语句覆盖率
- 按模块划分的热力图
2. 测试结果报告 (test-results/report/index.html)
- Playwright HTML Report(包含截图/trace/video)
- 失败用例自动截图
3. AI Summary 报告 (test-results/ai-summary.md)
- 保留现有自定义 reporter
- 用于 AI Agent 解析
4. 趋势报告 (test-results/trend.json)
- 每次运行的覆盖率变化
- 测试用例数量变化
- 失败率趋势
# .github/workflows/test.yml (新建)
name: Test Pipeline
on:
push:
branches: [master, develop]
pull_request:
branches: [master]
jobs:
# Job 1: 快速反馈(< 2 分钟)
lint-and-unit:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: audio_book_test
ports: ['3306:3306']
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install dependencies
run: |
cd server && npm ci
cd ../my-uniapp-vue3 && npm ci
- name: ESLint
run: |
cd server && npx eslint src/
cd ../my-uniapp-vue3 && npx eslint src/
- name: Unit Tests (L0)
run: npx vitest run --config tests/vitest.config.ts --reporter=verbose
- name: Component Tests (L1)
run: npx vitest run --config tests/vitest.component.config.ts
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage-l0-l1
path: coverage/
# Job 2: 集成测试(< 5 分钟)
integration:
needs: lint-and-unit
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: audio_book_test
ports: ['3307:3306']
redis:
image: redis:7
ports: ['6379:6379']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install & Migrate
run: |
cd server && npm ci
npx prisma migrate deploy
npm run build
- name: Start Server
run: |
cd server
TEST_DATABASE_URL=mysql://root:test@localhost:3307/audio_book_test \
REDIS_URL=redis://localhost:6379 \
npm run start:test &
sleep 10
- name: Integration Tests (L2)
run: npx vitest run --config tests/vitest.integration.config.ts
- name: API E2E Tests (L3)
run: npx playwright test --config tests/playwright.config.ts --project=api-e2e
# Job 3: UI E2E 测试(每日或手动触发)
ui-e2e:
needs: integration
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
services:
mysql: { as above }
redis: { as above }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install & Build
run: |
cd server && npm ci && npx prisma migrate deploy && npm run build
cd ../my-uniapp-vue3 && npm ci && npm run build:h5
- name: Start Full Stack
run: |
cd server && npm run start:test &
sleep 5
- name: UI E2E Tests (L4)
run: npx playwright test --config tests/playwright.config.ts --project=ui-e2e
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: test-results/report/
// package.json scripts 补充
{
"scripts": {
"test:unit": "vitest run --config tests/vitest.config.ts",
"test:unit:watch": "vitest --config tests/vitest.config.ts",
"test:component": "vitest run --config tests/vitest.component.config.ts",
"test:integration": "vitest run --config tests/vitest.integration.config.ts",
"test:api-e2e": "playwright test --config tests/playwright.config.ts --project=api-e2e",
"test:ui-e2e": "playwright test --config tests/playwright.config.ts --project=ui-e2e",
"test:critical": "playwright test tests/e2e/ui/critical-journeys/",
"test:all": "npm run test:unit && npm run test:integration && npm run test:api-e2e",
"test:coverage": "vitest run --config tests/vitest.config.ts --coverage",
"test:db:setup": "node tests/scripts/setup-test-db.js",
"test:db:clean": "node tests/scripts/clean-test-db.js"
}
}
| 门禁 | 标准 | 检查方式 | 不通过后果 |
|---|---|---|---|
| Lint 零错误 | ESLint 0 errors | CI 自动检查 | ❌ 禁止合并 |
| 类型检查通过 | tsc --noEmit 通过 | CI 自动检查 | ❌ 禁止合并 |
| 单元测试通过 | 全部 PASS,覆盖率达标 | CI 自动检查 | ❌ 禁止合并 |
| 集成测试通过 | 全部 PASS | CI 自动检查 | ❌ 禁止合并 |
| 5条关键旅程通过 | Critical Journeys 全部 PASS | CI 自动检查 | ❌ 禁止发版 |
| Code Review | 至少1人 Approved | GitHub PR | ❌ 禁止合并 |
| 无高危 Bug | 无 open 的 P0/P1 bug | 项目管理工具 | ⚠️ 需评审 |
// 全局最低标准
global: { lines: 80, branches: 70, functions: 80, statements: 80 }
// 核心模块特殊标准(不达标 → 禁止合并)
'server/src/modules/subscription/**': { lines: 95, branches: 90 }
'server/src/modules/tts/**': { lines: 90, branches: 85 }
'server/src/modules/payment/**': { lines: 90, branches: 85 }
'server/src/modules/book-generator/**': { lines: 75, branches: 65 }
| 级别 | 定义 | 示例 | 发版影响 |
|---|---|---|---|
| P0 致命 | 核心功能不可用/数据丢失/资金损失 | 支付扣款未到账、配额重复扣除 | ❌ 阻塞发版 |
| P1 严重 | 主要功能异常,有 workaround | 某音色合成失败、书籍生成卡住 | ❌ 阻塞发版 |
| P2 一般 | 次要功能异常 | 播放列表排序错误、搜索历史不显示 | ⚠️ 评估 |
| P3 轻微 | UI 样式问题 | 按钮间距不对、颜色偏差 | ✅ 可发版 |
目标:建立单元测试基础设施,覆盖 P1 核心模块
Week 1:
├── 安装 Vitest + 配置
├── 搭建 TestDataFactory + DB Helper
├── Mock 工厂(LLM / TTS / Payment)
├── 完成 subscription 模块单元测试(目标覆盖率 95%)
└── 完成 tts/circuit-breaker 单元测试(目标覆盖率 90%)
Week 2:
├── 完成 book-generator/state-machine 单元测试
├── 完成 payment 模块单元测试
├── 创建 CI workflow 文件
├── 建立覆盖率达到标检查
└── 团队 Code Review 规范培训
目标:API 集成测试 + 数据库集成测试
Week 3:
├── 数据库集成测试(Prisma CRUD + 事务)
├── auth-flow 集成测试(注册→登录→Token刷新)
├── book-generator-flow 集成测试(完整流水线)
└── tts-flow 集成测试(合成→存储→配额扣除)
Week 4:
├── payment-flow 集成测试(下单→支付→回调→会员更新)
├── subscription-flow 集成测试(套餐切换→配额计算→超额)
├── publish-flow 集成测试
└── 性能基准测试(关键 API 响应时间基线)
目标:修复旧 E2E + 新增关键旅程
Week 5:
├── 修复所有 test.skip() 用例
├── 改造回归测试断言(去除多值断言)
├── 实现 Journey 1-3(注册→TTS→书籍生成)
└── 前端页面添加 data-testid 属性
Week 6:
├── 实现 Journey 4-5(购买→发布)
├── 补充缺失的回归测试模块(14-16, 18-22)
├── 集成 CI 自动运行 E2E
└── 建立监控告警(失败率 > 5% 告警)
目标:测试文化建立 + 持续优化
├── 每 Sprint 新增功能必须有对应测试
├── 每月审查覆盖率趋势
├── 每季度执行一次性能测试
├── 建立测试数据匿名化机制
├── 引入变异测试(Mutation Testing)
└── 探索 AI 辅助测试用例生成
Week 1-2 Week 3-4 Week 5-6 长期
│ │ │ │
覆盖率: 0% ──▶ 30% ────▶ 55% ────▶ 70% ────▶ 80%+
│ │ │ │
里程碑: 基础建立 集成就绪 E2E 完整 文化形成
│ │ │ │
可发版: ❌ ⚠️ ✅ ✅
格式: {模块}-{组件}-{元素}
bookshelf:
data-testid="book-create-btn"
data-testid="book-title-input"
data-testid="book-list-item"
player:
data-testid="player-play-btn"
data-testid="player-progress-bar"
data-testid="player-speed-select"
member:
data-testid="member-plan-card-basic"
data-testid="member-upgrade-btn"
general:
data-testid="toast-success"
data-testid="toast-error"
data-testid="loading-spinner"
data-testid="empty-state"
# .env.test
NODE_ENV=test
TEST_DATABASE_URL=mysql://root:@localhost:3307/audio_book_test
REDIS_URL=redis://localhost:6379
SERVER_PORT=3199
AUTH_ENABLED=false # 测试环境关闭认证
TTS_PROVIDER=mock # 使用 Mock Provider
LLM_PROVIDER=mock # 使用 Mock LLM
PAYMENT_MODE=sandbox # 沙箱模式
OSS_BUCKET=test-bucket
# 开发时使用
npm run test:unit:watch # 监听模式运行单元测试
npm run test:unit -- --reporter=verbose # 详细输出
# 提交前使用
npm run test:all # 运行 L0+L1+L2+L3
npm run test:coverage # 生成覆盖率报告
# 发版前使用
npm run test:critical # 运行 5 条关键旅程 E2E
npm run test:all -- --coverage # 全覆盖 + 覆盖率检查
# 故障排查
npx playwright test --debug # Playwright 调试模式
npx playwright show-report # 查看上次测试报告
npx playwright test --trace on # 开启 trace 追踪
| 资源 | 链接 |
|---|---|
| Vitest 文档 | https://vitest.dev/ |
| Playwright 文档 | https://playwright.dev/ |
| Testing Library 最佳实践 | https://testing-library.com/docs/ |
| Martin Fowler - 测试金字塔 | https://martinfowler.com/bliki/TestPyramid.html |
| Google 测试博客 | https://testing.googleblog.com/ |
文档维护: 本文档由测试负责人维护,每次测试体系重大变更后更新。
下次审查: 2026-06-19