前端测试策略:单元、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

二、单元测试: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.1 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. API 设计与 BFF 层:REST、GraphQL、tRPC 选型与前后端协作
  2. WebAssembly 前端工程化实践:编译链、性能对比与混合架构
  3. 现代浏览器 API 与 Web 平台能力地图