测试与监控
测试金字塔
╱╲
╱ ╲ E2E 测试(少/慢)
╱ ╲ 核心用户流程
╱─────────────────╲
╱ ╲
╱ 集成测试 ╲
╱ 模块间交互、API ╲
╱───────────────────────────╲
╱ ╲
╱ 单元测试(多/快) ╲
╱ 函数/组件/工具类的独立测试 ╲
╱─────────────────────────────────────╲
| 层级 |
数量 |
速度 |
执行频率 |
目的 |
| 单元测试 |
多(70%+) |
快(毫秒级) |
每次保存/提交 |
验证最小代码单元 |
| 集成测试 |
中(20%) |
中(秒级) |
PR 前 |
验证模块间协作 |
| E2E 测试 |
少(10%) |
慢(分钟级) |
发布前/CI 主干 |
验证核心业务流程 |
单元测试
Jest
基础结构
describe('功能模块描述', () => {
test('具体测试场景', () => {
// Arrange - 准备数据
const input = 'hello';
// Act - 执行操作
const result = toUpperCase(input);
// Assert - 断言结果
expect(result).toBe('HELLO');
});
});
匹配器(Matchers)
expect(2 + 2).toBe(4); // 引用相等(基本类型)
expect({ a: 1 }).toEqual({ a: 1 }); // 值相等(递归对比)
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect([1, 2, 3]).toContain(2);
expect('hello').toMatch(/ello/);
expect(fn).toHaveBeenCalledWith('param');
expect(Promise.resolve('ok')).resolves.toBe('ok');
expect(Promise.reject('err')).rejects.toThrow('err');
Mock 函数和 Spy
// jest.fn() — 创建模拟函数
const mockFn = jest.fn().mockReturnValue('fixed');
mockFn('a', 'b');
expect(mockFn).toHaveBeenCalledWith('a', 'b');
expect(mockFn.mock.calls.length).toBe(1);
// jest.spyOn — 监听已有方法
const spy = jest.spyOn(console, 'log').mockImplementation(() => {});
// 执行代码
console.log('test');
expect(spy).toHaveBeenCalledWith('test');
spy.mockRestore(); // 恢复原函数
// 模块 mock
jest.mock('axios');
import axios from 'axios';
axios.get.mockResolvedValue({ data: { id: 1 } });
Vitest
| 特性 |
Vitest |
Jest |
| 编译 |
esbuild(原生快速) |
Babel/Jest transform |
| ESM |
原生 ESM 支持 |
需配置 |
| HMR |
支持热更新(文件修改自动重新运行相关测试) |
不支持 |
| API 兼容 |
兼容 Jest API(describe/test/expect/vi.fn()) |
— |
| 配置 |
复用 vite.config |
单独 jest.config |
| 速度 |
快(esbuild 编译) |
中 |
// Vitest 测试(API 与 Jest 几乎一致)
import { describe, it, expect, vi } from 'vitest';
describe('utils', () => {
it('should work', () => {
const fn = vi.fn();
fn();
expect(fn).toHaveBeenCalled();
});
});
定时器假时间
// Jest
jest.useFakeTimers();
jest.advanceTimersByTime(3000); // 快进 3 秒
// Vitest
vi.useFakeTimers();
vi.advanceTimersByTime(3000);
Vue 组件测试(@vue/test-utils)
// MyButton.vue
<template>
<button @click="$emit('click')" :class="{ active: isActive }">
<slot />
</button>
</template>
import { mount } from '@vue/test-utils';
import MyButton from './MyButton.vue';
test('emits click event', async () => {
const wrapper = mount(MyButton, {
slots: { default: 'Submit' },
props: { isActive: false }
});
expect(wrapper.text()).toBe('Submit');
expect(wrapper.classes()).not.toContain('active');
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
await wrapper.setProps({ isActive: true });
expect(wrapper.classes()).toContain('active');
});
| Jest 方法 |
Vue Test Utils 对应 |
说明 |
mount(Component, options) |
wrapper |
挂载(渲染子组件) |
shallowMount(Component, options) |
wrapper |
浅挂载(stub 子组件) |
wrapper.find(selector) |
DOMWrapper |
查找元素 |
wrapper.findAll(selector) |
DOMWrapper[] |
查找所有 |
wrapper.trigger(event) |
— |
触发事件 |
wrapper.setProps(props) |
— |
更新 props |
wrapper.emitted(event) |
— |
获取已触发的事件 |
React 组件测试(React Testing Library)
设计哲学: 按用户行为测试,不测试实现细节(不关心 state 变化、不调用组件方法、不测试内部函数)。
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('form submission', async () => {
render(<MyForm />);
// 按标签查找
const input = screen.getByLabelText('用户名');
await userEvent.type(input, '张三'); // 模拟逐字输入
const button = screen.getByRole('button', { name: /提交/i });
await userEvent.click(button);
// 等待异步操作
await waitFor(() => {
expect(screen.getByText('提交成功')).toBeInTheDocument();
});
});
| 查询方式 |
说明 |
优先级 |
getByRole |
ARIA 角色 |
最高 |
getByLabelText |
标签文本(form 标签关联) |
高 |
getByPlaceholderText |
placeholder |
中 |
getByText |
文本内容 |
中 |
getByTestId |
data-testid 属性 |
最低(避免使用) |
| 前缀 |
行为 |
getBy |
找不到抛错,适用于确定存在的元素 |
queryBy |
找不到返回 null,适用于判断元素不存在 |
findBy |
返回 Promise,等待元素出现(异步场景) |
组件 / E2E 测试
Playwright
| 特性 |
说明 |
| 跨浏览器 |
Chromium / Firefox / WebKit |
| locator API |
内置自动等待、重试、元素稳定性检测 |
| trace 回溯 |
记录操作日志 + DOM 快照 + 网络请求 + 截图,失败时可回放 |
| 自动等待 |
执行操作前自动等待元素可见/稳定/可交互 |
| Codegen |
录制用户操作生成测试代码 |
| 网络拦截 |
page.route() mock API 响应 |
| 组件测试 |
直接挂在组件,不依赖完整页面 |
import { test, expect } from '@playwright/test';
test('用户登录流程', async ({ page }) => {
await page.goto('https://example.com/login');
// locator API(自动等待)
await page.getByLabel('用户名').fill('admin');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
// 等待页面跳转
await page.waitForURL('**/dashboard');
await expect(page.getByText('欢迎回来')).toBeVisible();
// 网络请求断言
const response = await page.waitForResponse('**/api/user*');
expect(response.status()).toBe(200);
});
// Trace Viewer 记录
test('with trace', async ({ page, context }) => {
await context.tracing.start({ screenshots: true, snapshots: true });
// ... 测试执行
await context.tracing.stop({ path: 'trace.zip' });
});
Cypress
| 对比 |
Playwright |
Cypress |
| 浏览器支持 |
Chromium / Firefox / WebKit |
仅 Chromium(电子内核) |
| 语言 |
JS/TS |
JS/TS |
| 架构 |
浏览器外驱动(多进程) |
与浏览器同进程运行 |
| 时间旅行 |
部分(trace replay) |
原生(每一步的快照和状态) |
| 实时重放 |
截图 + 视频 + trace |
自动录制每一步 |
| 组件测试 |
支持 |
支持 |
异步测试
// waitFor(React Testing Library)- 等待直到断言通过
await waitFor(() => {
expect(screen.getByText('loaded')).toBeInTheDocument();
}, { timeout: 5000, interval: 100 });
// findBy - 隐式 waitFor
const element = await screen.findByText('loaded');
// act - 确保 React 状态更新被正确包装
await act(async () => {
render(<MyComponent />);
await userEvent.click(screen.getByRole('button'));
});
// Promise 断言
await expect(fetchData()).resolves.toEqual(expectedData);
await expect(fetchData()).rejects.toThrow('Network Error');
// 定时器假时间
jest.useFakeTimers();
jest.advanceTimersByTime(3000); // 快进 3 秒,跳过 setTimeout
expect(callback).toHaveBeenCalled();
jest.useRealTimers();
覆盖率
| 覆盖类型 |
含义 |
公式 |
| 语句覆盖 |
代码中每条语句是否被执行 |
执行语句数 / 总语句数 |
| 分支覆盖 |
每个条件分支是否都被执行(if/else/switch) |
执行分支数 / 总分支数 |
| 函数覆盖 |
每个函数是否被调用 |
调用函数数 / 总函数数 |
| 行覆盖 |
每行代码是否被执行 |
执行行数 / 总行数 |
// Jest 配置覆盖率阈值
// jest.config.js
module.exports = {
collectCoverage: true,
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
'./src/components/**': {
statements: 90,
}
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
],
};
错误监控
Sentry 集成
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
Sentry.init({
dsn: 'https://xxx@sentry.io/xxx',
environment: process.env.NODE_ENV, // production/staging/development
release: process.env.REACT_APP_VERSION,
integrations: [new BrowserTracing()],
tracesSampleRate: 0.2, // 性能采样率
// 面包屑—自动记录用户操作历史
beforeBreadcrumb(breadcrumb, hint) {
// 过滤掉不需要的 breadcrumb
if (breadcrumb.category === 'xhr' && breadcrumb.data?.url?.includes('health')) {
return null;
}
return breadcrumb;
},
});
// 手动上报错误
try {
dangerousOperation();
} catch (error) {
Sentry.captureException(error);
}
// 设置用户上下文
Sentry.setUser({ id: 'user-123', email: 'user@example.com' });
| Sentry 概念 |
说明 |
| Error Grouping |
按错误消息 + 堆栈指纹聚合同类错误 |
| Source Map |
构建时上传,还原混淆后代码到原始代码位置 |
| Breadcrumbs |
用户操作日志(点击、XHR、导航、控制台输出) |
| Release |
关联版本,快速定位哪个版本引入了错误 |
| Performance |
分布式追踪,前后端链路串联 |
Source Map 上传策略
构建 ──► 生成 .map 文件 ──► 上传到 Sentry(CI 中自动)
│
▼
不要发布到生产环境
# sentry-cli 自动上传
sentry-cli releases files $VERSION upload-sourcemaps ./dist --rewrite
生产环境不应提供 Source Map 文件(否则攻击者可还原源码)。安全做法: 上传到 Sentry(私有)后删除构建产物中的 .map 文件。
性能监控
Web Vitals 采集
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id, // 唯一标识,用于归因
};
// 发送到分析服务
navigator.sendBeacon('/analytics', JSON.stringify(body));
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
// Long Task 检测
const longTaskObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
// 上报 > 50ms 的长任务
console.warn('Long Task:', entry.duration, entry.attribution);
});
});
longTaskObserver.observe({ type: 'longtask', buffered: true });
// Element Timing - 自定义元素渲染时间
const elementObserver = new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log('Element rendered:', entry.identifier, entry.renderTime);
});
});
elementObserver.observe({ type: 'element', buffered: true });
自定义指标
| 指标 |
含义 |
实现方式 |
| FMP |
First Meaningful Paint — 首屏有意义内容时间 |
已废弃,LCP 代替 |
| TTI |
Time to Interactive — 页面完全可交互 |
Lighthouse 计算 |
| FID |
First Input Delay — 首次输入延迟 |
已被 INP 取代 |
可观测性
RUM vs Synthetic Monitoring
| 维度 |
RUM(真实用户监控) |
Synthetic(主动探测) |
| 数据来源 |
真实用户浏览器 |
模拟的脚本化请求 |
| 样本覆盖 |
真实用户地域/设备/网络 |
固定环境 |
| 优势 |
反映真实体验差异 |
可复现、稳定基线、端到端覆盖 |
| 劣势 |
样本偏差、受用户环境干扰 |
无法反映真实用户环境 |
| 工具 |
web-vitals + Datadog RUM / Sentry |
Lighthouse CI / WebPageTest |
| 适用 |
长期趋势监控、SLI/SLO 度量 |
发布前质量门禁、回归检测 |
OpenTelemetry 分布式 Tracing
用户请求 ──► 浏览器 ──► API Gateway ──► 微服务 A ──► 微服务 B
│ │ │ │ │
└──────────┴────────────┴──────────────┴──────────────┘
↑
traceId: abc-123
每个节点加入 traceId + spanId
形成完整的层级瀑布图
| 术语 |
含义 |
| traceId |
贯穿整个请求链路的唯一 ID |
| spanId |
单个服务或操作的唯一 ID |
| Parent Span |
调用链中上游的 span |
| rootSpan |
请求的起始 span,没有 parent |
// 前端 OpenTelemetry 集成
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
const provider = new WebTracerProvider();
provider.register({
contextManager: new ZoneContextManager(),
});
provider.addInstrumentation(
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: /.*/,
})
);