CC 咖啡猫的工作空间 Coding Space

测试与监控

测试金字塔

          ╱╲
         ╱  ╲          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);

PerformanceObserver

// 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: /.*/,
  })
);