前端测试策略:单元、E2E、视觉回归与验收测试

系统性前端测试体系建设:测试金字塔分层(单元/集成/E2E)、Vitest/Jest 单元测试实战、Vue Test Utils / React Testing Library 组件测试、MSW(Mock Service Worker)API 模拟、Cypress / Playwright E2E 对比、组件文档与测试结合(Storybook Interaction Tests)、视觉回归测试(Chromatic / Percy)、性能基准测试(Lighthouse CI)、测试覆盖率门禁与 CI 集成、TDD/BDD 实践模式。

没有测试的代码是累赘,有测试的代码是资产。 前端测试的本质不是「找bug」,而是「防止回归」和「提供重构信心」。一套合理的测试策略应该在速度与信心之间找到平衡点。


一、测试金字塔

1.1 分层模型

        /
       / \          E2E 测试(少而精)
      /   \         → 用户场景、关键路径
     /─────\
    /       \       集成测试(中等)
   /         \      → 组件交互、API 集成
  /───────────\
 /             \    单元测试(大量)
/               \   → 纯函数、工具类、组件行为
─────────────────

投入比例建议:单元 70% : 集成 20% : E2E 10%

1.2 各层关注点

层级范围速度工具成本
单元测试函数、Hook、单组件< 1sVitest / Jest低
集成测试组件树、API 调用5-30sTesting Library + MSW中
E2E 测试完整用户流程分钟级Cypress / Playwright高

二、测试数据工厂与工具函数

在编写大量测试时,重复构建测试数据和工具函数会导致测试代码膨胀和维护困难。引入工厂函数(Factory Functions)和Faker库可以显著提升测试数据构建效率:

// factories/user.factory.ts
import { faker } from '@faker-js/faker';
import type { User, UserRole } from '../types/user';

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    name: faker.person.fullName(),
    email: faker.internet.email(),
    avatar: faker.image.avatar(),
    role: faker.helpers.arrayElement<UserRole>(['admin', 'user', 'guest']),
    createdAt: faker.date.past(),
    ...overrides,
  };
}

export function createUsers(count: number): User[] {
  return Array.from({ length: count }, () => createUser());
}

// 测试中使用工厂函数
it('filters active users correctly', () => {
  const users = [
    createUser({ role: 'admin', name: 'Alice' }),
    createUser({ role: 'guest' }),
    createUser({ role: 'user' }),
  ];
  
  const activeUsers = filterActiveUsers(users);
  expect(activeUsers).toHaveLength(2);
  expect(activeUsers[0].name).toBe('Alice');
});

测试数据 Builder 模式:对于需要链式构建的复杂对象,Builder 模式更优雅:

// builders/order.builder.ts
class OrderBuilder {
  private order: Partial<Order> = {};

  withId(id: string) {
    this.order.id = id;
    return this;
  }

  withItems(items: OrderItem[]) {
    this.order.items = items;
    return this;
  }

  withTotal(total: number) {
    this.order.total = total;
    return this;
  }

  build(): Order {
    return {
      id: faker.string.uuid(),
      items: [],
      total: 0,
      status: 'pending',
      ...this.order,
    } as Order;
  }
}

// 使用
const order = new OrderBuilder()
  .withTotal(100)
  .withItems([createOrderItem()])
  .build();

三、单元测试:Vitest

2.1 为什么选择 Vitest

特性VitestJest
速度快(原生 ESM)中(需 babel 转译)
ESM 支持原生需配置
TypeScript原生需 ts-jest
Vite 生态无缝需适配
API 兼容与 Jest 基本一致标准
推荐✅ Vite 项目首选⚠️ 存量项目

2.2 配置与实战

pnpm add -D vitest @vitest/ui @testing-library/jest-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,                 // 全局 expect/desribe/it
    environment: 'jsdom',          // 浏览器环境模拟
    include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
        statements: 80
      }
    }
  }
});
// utils/formatDate.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, isValidDate } from './formatDate';

describe('formatDate', () => {
  it('formats ISO date to display string', () => {
    expect(formatDate('2024-01-15')).toBe('2024年1月15日');
  });

  it('handles invalid input gracefully', () => {
    expect(formatDate('invalid')).toBe('—');
  });

  it('respects locale parameter', () => {
    expect(formatDate('2024-01-15', 'en-US')).toBe('Jan 15, 2024');
  });
});

describe('isValidDate', () => {
  it.each([
    ['2024-01-15', true],
    ['2024-02-30', false],  // 不存在日期
    ['not-a-date', false],
    ['', false],
  ])('isValidDate(%s) = %s', (input, expected) => {
    expect(isValidDate(input)).toBe(expected);
  });
});

三、组件测试

3.2 快照测试策略

快照测试(Snapshot Testing)通过比较序列化后的输出与保存的基准快照,检测 UI 或数据结构的意外变化。合理使用快照测试可以大幅减少回归错误:

// 组件快照测试
import { mount } from '@vue/test-utils';
import UserProfile from './UserProfile.vue';

it('renders user profile correctly', () => {
  const wrapper = mount(UserProfile, {
    props: { user: createUser({ name: '张三', role: 'admin' }) }
  });
  
  expect(wrapper.html()).toMatchSnapshot();
});

// 数据结构快照测试
it('matches the order response schema', () => {
  const response = {
    id: 'order-001',
    items: [
      { productId: 'p1', quantity: 2, price: 29.99 },
      { productId: 'p2', quantity: 1, price: 49.99 },
    ],
    total: 109.97,
    status: 'pending',
  };
  
  expect(response).toMatchSnapshot();
  // 或针对特定字段进行快照
  expect(response).toMatchInlineSnapshot();
});

快照测试最佳实践:

  1. 只快照纯数据:避免快照包含随机值、时间戳或动态 ID
  2. 快照失效时仔细审查:不要无脑 -u 更新,确认变化是否预期
  3. 小快照优于大快照:大的 HTML 快照失败时难以定位问题
  4. 用 toMatchFileSnapshot 替代行内快照:对大型输出更友好
  5. 定期清理过期快照:Vitest 会自动标记未使用的快照

何时使用/避免快照测试:

✅ 适合快照测试❌ 避免快照测试
纯数据结构的序列化输出包含随机 ID 或时间戳
CSS 类名验证第三方组件的完整 HTML
错误消息格式频繁变更的 UI 布局
配置文件输出国际化翻译文本

3.3 Vue 组件测试(Vue Test Utils)

// components/UserCard.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import UserCard from './UserCard.vue';

describe('UserCard', () => {
  it('renders user info correctly', () => {
    const wrapper = mount(UserCard, {
      props: {
        user: { id: '1', name: 'Alice', avatar: 'alice.jpg', role: 'admin' }
      }
    });

    expect(wrapper.text()).toContain('Alice');
    expect(wrapper.text()).toContain('admin');
    expect(wrapper.find('img').attributes('src')).toBe('alice.jpg');
  });

  it('emits click event with user id', async () => {
    const wrapper = mount(UserCard, {
      props: { user: { id: '1', name: 'Alice', avatar: '', role: 'user' } }
    });

    await wrapper.find('button').trigger('click');
    expect(wrapper.emitted('select')).toHaveLength(1);
    expect(wrapper.emitted('select')![0]).toEqual(['1']);
  });

  it('shows fallback avatar when none provided', () => {
    const wrapper = mount(UserCard, {
      props: { user: { id: '1', name: 'Bob', avatar: '', role: 'user' } }
    });

    expect(wrapper.find('img').attributes('src')).toBe('/default-avatar.png');
  });
});

3.2 React 组件测试(Testing Library)

// components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
import '@testing-library/jest-dom/vitest';

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button')).toHaveTextContent('Click me');
  });

  it('calls onClick when clicked', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click</Button>);

    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when loading', () => {
    render(<Button loading>Submit</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('applies correct variant styles', () => {
    render(<Button variant="danger">Delete</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-danger');
  });
});

3.3 MSW:API Mock

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json({
      data: [
        { id: '1', name: 'Alice' },
        { id: '2', name: 'Bob' }
      ]
    });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: '3', ...body }, { status: 201 });
  }),

  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    if (id === '999') {
      return new HttpResponse(null, { status: 404 });
    }
    return HttpResponse.json({ id, name: 'Test User' });
  }),
];
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

四、E2E 测试

4.1 Cypress vs Playwright

维度CypressPlaywright
浏览器控制自带 Electron,外部浏览器有限制Chromium/Firefox/WebKit 原生
并行执行需 Cypress Cloud/商业版原生支持(sharding)
速度较快更快(多 worker)
调试极佳(时间旅行、截图)好(trace viewer)
跨域受限原生支持
API 测试较弱内置 request
CI 集成简单简单
组件测试支持实验性
推荐✅ 传统选择✅ 新项目/大并发

4.2 Playwright 实战

npm init playwright@latest
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('successful login redirects to dashboard', async ({ page }) => {
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Dashboard');
  });

  test('shows error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email"]', 'wrong@example.com');
    await page.fill('[data-testid="password"]', 'wrong');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('Invalid credentials');
  });

  test('persists session after refresh', async ({ page, context }) => {
    // 登录
    await page.fill('[data-testid="email"]', 'user@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login-button"]');

    // 刷新
    await page.reload();
    await expect(page).toHaveURL('/dashboard');
  });
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,
  reporter: [['html', { open: 'never' }], ['json', { outputFile: 'test-results.json' }]],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } },
  ],
});

五、Storybook:文档 + 交互测试

5.1 Story 即测试

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { Button } from './Button';

const meta: Meta<typeof Button> = {
  component: Button,
};
export default meta;

type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: { variant: 'primary', children: 'Click me' },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button');
    await expect(button).toHaveClass('btn-primary');
    await userEvent.click(button);
  }
};

export const Loading: Story = {
  args: { loading: true, children: 'Submit' },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getByRole('button')).toBeDisabled();
  }
};

5.2 视觉回归测试(Chromatic)

npx chromatic --project-token=xxx

每次 Storybook 构建后自动对比截图,检测 UI 变化:

  • 像素级差异标记
  • 团队成员 Review & Approve
  • 集成 CI,阻止未审批的 UI 变更合并

六、性能测试:Lighthouse CI

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install && npm run build
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli@0.14.x
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/'],
      startServerCommand: 'npm run preview',
    },
    assert: {
      assertions: {
        'categories:performance': ['warn', { minScore: 0.9 }],
        'categories:accessibility': ['error', { minScore: 0.95 }],
        'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
      }
    }
  }
};

七、CI 集成与门禁

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install
      - run: pnpm test:unit --coverage
      - uses: codecov/codecov-action@v5

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install
      - run: pnpm build
      - run: pnpm test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

八、测试策略总结

推荐测试投入比(按代码行数/用例数):

单元测试(70%)        组件测试(15%)       E2E(10%)         视觉回归(5%)
├── 工具函数           ├── 交互行为          ├── 核心用户路径     ├── 关键页面截图
├── 状态逻辑           ├── 状态变化          ├── 登录/支付/提交   ├── 组件多状态截图
├── Hook/Composables   ├── Props 传递        ├── 跨页面流程       ├── diff 自动检测
└── 纯函数业务逻辑     └── 事件处理          └── 响应式布局       └── CI 自动审批

不要测试:
❌ 第三方库内部实现
❌ 纯样式(交给视觉回归)
❌ 琐碎的 getters(如 return this.value)

参考与延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. 前端 GraphQL 客户端集成:Apollo Client、Relay 与 urql 选型
  2. 前端性能调试实战:从首屏加载到运行时瓶颈
  3. 前端表单与验证架构:设计模式、状态管理与无障碍