docs/archives/131-testing-redesign/findings.md
| 指标 | Vitest | Jest |
|---|---|---|
| 执行速度 | 快 30-70% | 基准 |
| 冷启动 | 4x 更快(esbuild) | 基准(Babel/ts-jest) |
| 内存占用 | 低 30% | 基准 |
| Watch 模式 | HMR,近瞬时 | 需要重新运行 |
真实基准测试:
Vitest:
Jest:
Vitest:
Jest:
Jest:
Vitest:
✅ 保持 Vitest(当前已使用)
理由:
来源:Medium - Jest vs Vitest 2025
| 指标 | Playwright | Cypress |
|---|---|---|
| 并行执行 | ✅ 内置,免费 | ⚠️ 需付费或自行配置 |
| 执行速度 | 快 35-45%(并行) | 基准 |
| 跨浏览器 | Chromium/Firefox/WebKit | Chromium/Firefox(有限) |
| 移动设备模拟 | ✅ 原生支持 | ⚠️ 有限 |
Playwright:
Cypress:
Playwright 适合:
Cypress 适合:
✅ 保持 Playwright(当前已使用)
理由:
来源:BugBug - Cypress vs Playwright 2025 来源:Medium - Cypress vs Playwright 2025
| 方案 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| MSW (Mock Service Worker) | 网络层拦截,浏览器+Node 通用,类型安全 | 初始配置复杂 | ⭐⭐⭐⭐⭐ |
| nock | 简单易用,HTTP mocking | 仅支持 Node.js | ⭐⭐⭐ |
| Polly.js | 自动录制-回放 | 维护不活跃(2021 年后) | ⭐⭐ |
| 自定义 VCR | 完全控制 | 开发成本高 | ⭐⭐⭐⭐ |
网络层拦截:
// MSW 使用 Service Worker API 拦截真实请求
// 无需修改生产代码
fetch('/api/optimize') // 会被 MSW 拦截
框架无关:
类型安全:
// 路径参数、请求体、响应体都有类型
http.post<OptimizeRequest, OptimizeResponse>('/api/optimize', ...)
最佳实践(2025-2026):
// mocks/handlers.ts
export const handlers = [
http.post('/api/optimize', () => {
return HttpResponse.json({ optimizedPrompt: '...' })
})
]
// Node.js (Vitest)
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Browser (Playwright)
const worker = setupWorker(...handlers)
await worker.start()
// 模拟延迟
http.get('/api/slow', () => delay(2000))
// 模拟错误
http.get('/api/error', () => HttpResponse.error())
// 模拟流式响应(需自定义)
http.post('/api/stream', async () => {
const stream = new ReadableStream(...)
return new HttpResponse(stream)
})
来源:MSW 官方文档 来源:Callstack - MSW 综合指南
推荐方案:MSW + 自定义 Fixtures 管理
┌────────────────────────────────────────────┐
│ 测试代码 │
│ test('优化提示词', async () => { ... }) │
└────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ VCR Middleware │
│ - 检测 fixture 是否存在 │
│ - 存在: MSW 回放 fixture │
│ - 不存在: 真实 API 并录制 │
└────────────────────────────────────────────┘
↓
┌───────────┴──────────┐
↓ ↓
┌───────────────┐ ┌──────────────┐
│ Mock 模式 │ │ 真实 API │
│ MSW handlers │ │ 录制响应 │
└───────────────┘ └──────────────┘
✅ MSW + 自定义 Fixtures
理由:
| 方案 | 类型 | 优点 | 缺点 | 成本 |
|---|---|---|---|---|
| Playwright Visual Testing | 内置代码 | 免费,集成简单,本地运行 | 像素级敏感,baseline 管理需手动 | 免费 |
| Percy | 云服务 | 智能对比,跨浏览器,UI 审查 | 依赖外部服务,收费 | $149/月起 |
| Chromatic | 云服务(Storybook) | Storybook 集成,组件驱动 | 限于 Storybook,收费 | $99/月起 |
| Applitools Eyes | 云服务(AI) | AI 驱动,智能忽略差异 | 贵,依赖外部 | $799/月起 |
基本用法:
test('视觉回归测试', async ({ page }) => {
await page.goto('/')
// 生成 baseline 或对比
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // 允许 100 像素差异
threshold: 0.2, // 20% 差异阈值
animations: 'disabled' // 禁用动画
})
})
Baseline 管理:
# 首次运行:生成 baseline
pnpm test:e2e --update-snapshots
# 后续运行:自动对比
pnpm test:e2e
# 失败时:生成对比图
# tests/e2e/.screenshots/
# ├── homepage-actual.png
# ├── homepage-expected.png
# └── homepage-diff.png
优点:
缺点:
最佳实践:
✅ Playwright Visual Testing
理由:
未来考虑:
| 特性 | Vue Test Utils | Testing Library (Vue) |
|---|---|---|
| 哲学 | 实现细节测试 | 用户行为测试 |
| API 风格 | 包装器,完全访问组件内部 | 查询 DOM,模拟用户交互 |
| 学习曲线 | Vue 特定,需了解组件 API | 框架无关,接近用户视角 |
| 重构友好 | ⚠️ 实现变化需修改测试 | ✅ UI 不变则测试不变 |
Vue Test Utils 示例:
const wrapper = mount(Component)
wrapper.vm.someMethod() // 直接访问组件实例
expect(wrapper.vm.someData).toBe('value')
Testing Library 示例:
render(Component)
const button = screen.getByRole('button', { name: /submit/i })
await userEvent.click(button)
expect(screen.getByText('Success')).toBeInTheDocument()
✅ Vue Test Utils(主要)+ Testing Library(补充)
理由:
指导原则:
| 层级 | 推荐工具 | 决策 |
|---|---|---|
| 单元/集成测试 | Vitest 4.0 | ✅ 保持现有选择 |
| E2E 测试 | Playwright 1.56 | ✅ 保持现有选择 |
| HTTP Mocking | MSW 2.0 + 自定义 VCR | ✅ 新增实现 |
| 视觉回归 | Playwright Visual Testing | ✅ 新增实现 |
| Vue 组件测试 | Vue Test Utils + Testing Library | ✅ 保持+补充 |
| Pinia 测试 | 现有 pinia-test-helpers | ✅ 保持+增强 |
关键决策:
下一步行动:
测试文件统计(2026-01-09 探索):
测试框架:
测试配置文件:
vitest.config.ts (UI/Web) - jsdom 环境,5 秒超时vitest.config.js (Core) - node 环境,30 秒超时playwright.config.ts - Chromium 浏览器,端口 15555packages/ui/tests/setup.ts - 全局测试设置(i18n, Naive UI, Mock APIs)packages/core/tests/setup.js - Core 全局设置(localStorage Mock)测试辅助工具:
packages/ui/tests/utils/pinia-test-helpers.ts - Pinia 测试工具
createTestPinia() - 创建测试 Pinia 实例createPreferenceServiceStub() - PreferenceService stubwithMockPiniaServices() - 自动清理的测试入口UI 包测试薄弱:
Desktop/Extension 完全无测试:
性能测试缺失:
/packages/core/tests/performance 目录存在但为空无法发现 UI 错误:
测试不可靠:
real-api.test.ts)执行效率低:
重构背景 (commit 5ea1004):
关键风险点(需重点测试):
前端框架:
核心服务 (packages/core/src/services/):
多平台支持:
方案 A: 全局 console spy
// tests/setup.ts
const originalError = console.error
const originalWarn = console.warn
const errors: string[] = []
global.console.error = (...args) => {
errors.push(args.join(' '))
originalError(...args)
}
afterEach(() => {
if (errors.length > 0) {
throw new Error(`Console errors detected: ${errors.join('\n')}`)
}
errors.length = 0
})
优点:
缺点:
方案 B: Vue warn handler
// tests/setup.ts
import { createApp } from 'vue'
const app = createApp({})
app.config.warnHandler = (msg, instance, trace) => {
throw new Error(`Vue warning: ${msg}\n${trace}`)
}
优点:
缺点:
推荐: 方案 A + 方案 B 结合,白名单过滤合法警告
方案: page.on('console') 监听器
// playwright.config.ts
test.beforeEach(async ({ page }) => {
page.on('console', msg => {
if (msg.type() === 'error' || msg.type() === 'warning') {
throw new Error(`Console ${msg.type()}: ${msg.text()}`)
}
})
page.on('pageerror', error => {
throw new Error(`Uncaught exception: ${error.message}`)
})
})
优点:
缺点:
推荐: 在 Playwright 全局配置中启用
| 方案 | 工具 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|---|
| 截图对比 | Playwright Visual Testing | 内置,无需额外服务 | 像素级对比敏感 | ⭐⭐⭐⭐ |
| 云端服务 | Percy, Chromatic | 智能对比,UI 审查 | 收费,依赖外部服务 | ⭐⭐⭐ |
| DOM 结构验证 | Testing Library | 快速,稳定 | 无法检测样式问题 | ⭐⭐⭐⭐⭐ |
推荐方案: DOM 结构验证 + Playwright 截图对比
// tests/e2e/visual-regression.spec.ts
test('Basic workspace 视觉对比', async ({ page }) => {
await page.goto('/')
await page.getByText(/Basic.*System/i).click()
// 生成 baseline 或对比
await expect(page).toHaveScreenshot('basic-system-workspace.png', {
maxDiffPixels: 100, // 允许 100 像素差异
threshold: 0.2 // 20% 差异阈值
})
})
Baseline 管理:
pnpm test:e2e --update-snapshots 生成 baselinetests/e2e/.screenshots/优点:
缺点:
// packages/ui/tests/unit/components/BasicSystemWorkspace.spec.ts
test('应该渲染所有必需元素', () => {
const wrapper = mount(BasicSystemWorkspace)
// 验证关键元素存在
expect(wrapper.find('[data-testid="prompt-input"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="optimize-button"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="test-area"]').exists()).toBe(true)
// 验证 CSS 类
expect(wrapper.find('.workspace-container').classes()).toContain('theme-light')
// 验证可见性
expect(wrapper.find('[data-testid="optimize-button"]').isVisible()).toBe(true)
})
优点:
缺点:
推荐: 组件测试用 DOM 验证,E2E 测试用截图对比
// packages/ui/tests/integration/state-sync.spec.ts
test('Store 更新应同步到 UI', async () => {
const { pinia } = createTestPinia()
const wrapper = mount(BasicSystemWorkspace, {
global: { plugins: [pinia] }
})
const store = useBasicSystemSession(pinia)
// 更新 Store
store.updatePrompt('New Prompt')
await wrapper.vm.$nextTick()
// 验证 UI 同步
const input = wrapper.find('[data-testid="prompt-input"]')
expect(input.element.value).toBe('New Prompt')
})
test('UI 更新应同步到 Store', async () => {
const { pinia } = createTestPinia()
const wrapper = mount(BasicSystemWorkspace, {
global: { plugins: [pinia] }
})
const store = useBasicSystemSession(pinia)
const input = wrapper.find('[data-testid="prompt-input"]')
// 更新 UI
await input.setValue('User Input')
// 验证 Store 同步
expect(store.prompt).toBe('User Input')
})
检测响应式失效:
test('computed 应正确触发', async () => {
const { pinia } = createTestPinia()
const store = useBasicSystemSession(pinia)
// 监听 computed 变化
let computedTriggered = false
const stopWatch = watch(
() => store.hasOptimizedResult,
() => { computedTriggered = true }
)
// 触发依赖变化
store.updateOptimizedResult({
optimizedPrompt: 'Result',
reasoning: 'Reason',
chainId: 'chain',
versionId: 'ver'
})
await nextTick()
expect(computedTriggered).toBe(true)
stopWatch()
})
按钮点击响应:
test('优化按钮应触发优化流程', async () => {
const mockOptimize = vi.fn().mockResolvedValue({
optimizedPrompt: 'Optimized',
reasoning: 'Reason',
chainId: 'chain',
versionId: 'ver'
})
const { pinia, services } = createTestPinia({
promptService: { optimizePrompt: mockOptimize }
})
const wrapper = mount(BasicSystemWorkspace, {
global: { plugins: [pinia] }
})
// 设置输入
const store = useBasicSystemSession(pinia)
store.updatePrompt('Test Prompt')
// 点击按钮
const button = wrapper.find('[data-testid="optimize-button"]')
await button.trigger('click')
// 验证行为
expect(mockOptimize).toHaveBeenCalledWith(
'Test Prompt',
expect.any(Object)
)
await wrapper.vm.$nextTick()
expect(store.optimizedPrompt).toBe('Optimized')
})
表单提交流程:
test('表单提交应验证并保存', async () => {
const { page } = await context.newPage()
await page.goto('/')
// 填写表单
await page.fill('[data-testid="title-input"]', 'Test Title')
await page.fill('[data-testid="content-input"]', 'Test Content')
// 提交
const submitButton = page.getByRole('button', { name: /保存/i })
await submitButton.click()
// 验证成功提示
await expect(page.locator('.n-message')).toContainText('保存成功')
// 验证数据持久化
await page.reload()
await expect(page.locator('[data-testid="title-input"]')).toHaveValue('Test Title')
})
模态框行为:
test('模态框关闭应清理状态', async () => {
const wrapper = mount(ImportExportDialog, {
props: { show: true }
})
// 触发关闭
await wrapper.find('[data-testid="close-button"]').trigger('click')
// 验证 emit
expect(wrapper.emitted('update:show')).toBeTruthy()
expect(wrapper.emitted('update:show')[0]).toEqual([false])
// 验证状态清理
const internalState = wrapper.vm.exportData
expect(internalState).toBeNull()
})
| 库 | 优点 | 缺点 | 推荐度 |
|---|---|---|---|
| MSW (Mock Service Worker) | 拦截 fetch/XHR,支持浏览器和 Node | 需要手动编写 handlers | ⭐⭐⭐⭐⭐ |
| nock | HTTP mocking,简单易用 | 仅支持 Node.js | ⭐⭐⭐ |
| Polly.js | 自动录制-回放,适配器丰富 | 维护不活跃(最后更新 2021) | ⭐⭐ |
| 自定义 VCR | 完全控制,定制化强 | 开发成本高 | ⭐⭐⭐⭐ |
推荐方案: MSW + 自定义 Fixtures 管理
┌─────────────────────────────────────────────────────┐
│ 测试代码 │
│ test('优化提示词', async () => { ... }) │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ VCR Middleware │
│ - 检测 fixture 是否存在 │
│ - 存在: 回放 fixture (Mock) │
│ - 不存在: 调用真实 API 并录制 │
└─────────────────────────────────────────────────────┘
↓
┌──────────────┴──────────────┐
↓ ↓
┌──────────────────┐ ┌──────────────────┐
│ Mock 模式 │ │ 真实 API 模式 │
│ MSW handlers │ │ 真实 LLM 服务 │
│ 读取 fixtures │ │ 录制响应 │
└──────────────────┘ └──────────────────┘
packages/core/tests/fixtures/
├── llm/
│ ├── openai/
│ │ ├── chat-completion-simple.json
│ │ ├── chat-completion-streaming.json
│ │ └── error-rate-limit.json
│ ├── gemini/
│ │ └── generate-content.json
│ └── deepseek/
│ └── chat-completion.json
├── prompt/
│ ├── optimize-basic-system.json
│ ├── optimize-context-multi.json
│ └── test-prompt.json
└── image/
├── text2image-success.json
└── image2image-success.json
Fixture 格式:
{
"request": {
"provider": "openai",
"model": "gpt-4",
"messages": [
{ "role": "user", "content": "帮我写一封邮件" }
],
"stream": true
},
"response": {
"type": "streaming",
"chunks": [
{ "content": "尊敬的", "timestamp": 0 },
{ "content": "张经理", "timestamp": 50 },
{ "content": ":", "timestamp": 100 }
],
"finalResult": {
"content": "尊敬的张经理:...",
"usage": { "prompt_tokens": 10, "completion_tokens": 50 }
}
},
"metadata": {
"recordedAt": "2026-01-09T10:30:00Z",
"scenarioName": "optimize-basic-system",
"duration": 1500
}
}
// packages/core/tests/utils/vcr.ts
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
interface VCROptions {
fixturePath: string
mode: 'auto' | 'record' | 'replay' | 'off'
}
export class VCR {
constructor(private options: VCROptions) {}
async intercept<T>(
key: string,
realFn: () => Promise<T>
): Promise<T> {
const fixturePath = this.getFixturePath(key)
// 模式判断
if (this.options.mode === 'off') {
return realFn()
}
if (this.options.mode === 'replay' ||
(this.options.mode === 'auto' && existsSync(fixturePath))) {
// 回放模式
const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'))
return this.simulateResponse(fixture)
}
if (this.options.mode === 'record' ||
(this.options.mode === 'auto' && !existsSync(fixturePath))) {
// 录制模式
const result = await realFn()
const fixture = this.serializeResult(key, result)
writeFileSync(fixturePath, JSON.stringify(fixture, null, 2))
return result
}
}
private simulateResponse<T>(fixture: any): Promise<T> {
// 模拟延迟
return new Promise(resolve => {
setTimeout(() => {
resolve(fixture.response.finalResult)
}, fixture.metadata.duration || 100)
})
}
private getFixturePath(key: string): string {
return join(this.options.fixturePath, `${key}.json`)
}
}
// packages/core/tests/utils/stream-simulator.ts
export class StreamSimulator {
constructor(private chunks: Array<{ content: string, timestamp: number }>) {}
async *generate(): AsyncGenerator<string> {
let lastTimestamp = 0
for (const chunk of this.chunks) {
// 模拟真实延迟
const delay = chunk.timestamp - lastTimestamp
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
yield chunk.content
lastTimestamp = chunk.timestamp
}
}
}
// 使用示例
const simulator = new StreamSimulator(fixture.response.chunks)
for await (const chunk of simulator.generate()) {
callback(chunk)
}
// vitest.config.ts
export default defineConfig({
test: {
env: {
// 默认使用 Mock(VCR 回放)
VCR_MODE: process.env.VCR_MODE || 'auto',
// 可选: 强制使用真实 API
ENABLE_REAL_LLM: process.env.ENABLE_REAL_LLM || 'false'
}
}
})
测试命令:
# 默认: 自动模式(有 fixture 则回放,无则录制)
pnpm test
# 强制录制(更新所有 fixtures)
VCR_MODE=record pnpm test
# 强制回放(仅使用 fixtures,无则失败)
VCR_MODE=replay pnpm test
# 禁用 VCR(始终使用真实 API)
VCR_MODE=off pnpm test
# 或
ENABLE_REAL_LLM=true pnpm test
提交前测试必须 < 10 分钟,分层如下:
| 层级 | 执行时间 | 测试类型 | 说明 |
|---|---|---|---|
| Fast | 1-2 分钟 | 单元测试(纯逻辑) | 无 I/O,无 Mock,纯计算 |
| Standard | 3-4 分钟 | 单元+集成(Mock) | VCR 回放,Pinia 测试 |
| Full | 5-6 分钟 | E2E(浏览器) | Playwright,视觉回归 |
| Total | < 10 分钟 | 提交前完整测试 | Fast + Standard + Full |
Vitest 并行化:
// vitest.config.ts
export default defineConfig({
test: {
// 最大并发 workers(CPU 核心数 - 1)
maxWorkers: Math.max(1, os.cpus().length - 1),
// 最小并发 workers
minWorkers: 1,
// 每个 worker 隔离模式
pool: 'threads', // 或 'forks'
// 超时配置
testTimeout: 5000,
hookTimeout: 10000
}
})
Playwright 并行化:
// playwright.config.ts
export default defineConfig({
// 并发 workers
workers: process.env.CI ? 1 : undefined, // CI 串行,本地并发
// Sharding(分片执行)
shard: process.env.SHARD ? {
current: parseInt(process.env.SHARD_INDEX),
total: parseInt(process.env.SHARD_TOTAL)
} : undefined,
// 失败重试
retries: process.env.CI ? 2 : 0
})
CI 分片执行:
# .github/workflows/test.yml
jobs:
e2e:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Run E2E tests (shard ${{ matrix.shard }}/4)
run: pnpm test:e2e
env:
SHARD_INDEX: ${{ matrix.shard }}
SHARD_TOTAL: 4
// packages/ui/tests/unit/slow.spec.ts
test.skipIf(process.env.SKIP_SLOW === 'true')(
'大型数据集性能测试',
async () => {
// 耗时测试
},
{ timeout: 60000 }
)
快速模式:
# 跳过慢速测试(提交前快速验证)
SKIP_SLOW=true pnpm test
# 完整测试(CI 或发布前)
pnpm test
问题:
待调研:
问题:
待实现:
问题:
待调研:
@playwright/test 的 Electron 支持完成 Phase 1 调研
开始 Phase 2 实现
输出架构文档
architecture.mdtask_plan.md 决策日志