跳转至

前端测试

前端测试

📚 章节目标

本章节将全面介绍前端测试的各种技术和工具,包括 Jest 、 Cypress 、 Playwright 、测试覆盖率等,帮助学习者掌握前端测试的核心方法。

学习目标

  1. 理解前端测试的核心概念
  2. 掌握 Jest 单元测试
  3. 掌握 Cypress E2E 测试
  4. 掌握 Playwright 自动化测试
  5. 掌握测试覆盖率分析
  6. 理解测试最佳实践

🧪 前端测试概述

1. 测试类型

JavaScript
// 前端测试类型
// 1. 单元测试 - 测试独立的功能单元
// 2. 集成测试 - 测试多个组件的交互
// 3. E2E测试 - 测试完整的用户流程
// 4. 视觉回归测试 - 测试UI外观
// 5. 性能测试 - 测试应用性能

// 单元测试示例
function add(a, b) {
  return a + b;
}

test('adds 1 + 2 to equal 3', () => {
  expect(add(1, 2)).toBe(3);
});

// 集成测试示例
test('user can login and see dashboard', () => {
  render(<App />);
  fireEvent.change(screen.getByLabelText('Username'), {
    target: { value: 'testuser' },
  });
  fireEvent.change(screen.getByLabelText('Password'), {
    target: { value: 'password' },
  });
  fireEvent.click(screen.getByText('Login'));
  expect(screen.getByText('Dashboard')).toBeInTheDocument();
});

// E2E测试示例
test('user can complete purchase flow', async () => {
  await page.goto('https://example.com');
  await page.click('text=Products');
  await page.click('text=Add to Cart');
  await page.click('text=Checkout');
  await page.fill('input[name="email"]', 'test@example.com');
  await page.click('text=Place Order');
  await expect(page.locator('text=Order Confirmed')).toBeVisible();
});

2. 测试金字塔

Text Only
        /\
       /E2E\        少量E2E测试
      /------\
     / 集成测试 \     适量集成测试
    /------------\
   /   单元测试    \   大量单元测试
  /----------------\

📝 Jest 单元测试

1. Jest 基础

1.1 基本配置

JavaScript
// jest.config.js
module.exports = {
  // 测试环境
  testEnvironment: 'jsdom',

  // 测试文件匹配
  testMatch: [
    '**/__tests__/**/*.[jt]s?(x)',
    '**/?(*.)+(spec|test).[jt]s?(x)',
  ],

  // 转换配置
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
    '^.+\\.(js|jsx)$': 'babel-jest',
  },

  // 模块路径别名
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },

  // 覆盖率配置
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/main.{js,jsx,ts,tsx}',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
  ],

  // 覆盖率阈值
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

1.2 基本测试

JavaScript
// math.test.js
import { add, subtract, multiply, divide } from './math';

describe('Math functions', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(add(1, 2)).toBe(3);
  });

  test('subtracts 5 - 3 to equal 2', () => {
    expect(subtract(5, 3)).toBe(2);
  });

  test('multiplies 2 * 3 to equal 6', () => {
    expect(multiply(2, 3)).toBe(6);
  });

  test('divides 6 / 2 to equal 3', () => {
    expect(divide(6, 2)).toBe(3);
  });

  test('divides by zero throws error', () => {
    expect(() => divide(6, 0)).toThrow('Cannot divide by zero');
  });
});

2. React 组件测试

2.1 使用 React Testing Library

JavaScript
// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Button from './Button';

describe('Button component', () => {
  test('renders button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  test('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

2.2 测试表单

JavaScript
// LoginForm.test.js
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoginForm from './LoginForm';

describe('LoginForm component', () => {
  test('renders login form', () => {
    render(<LoginForm />);
    expect(screen.getByLabelText('Username')).toBeInTheDocument();
    expect(screen.getByLabelText('Password')).toBeInTheDocument();
    expect(screen.getByText('Login')).toBeInTheDocument();
  });

  test('shows error when fields are empty', async () => {
    render(<LoginForm />);
    fireEvent.click(screen.getByText('Login'));
    await waitFor(() => {
      expect(screen.getByText('Username is required')).toBeInTheDocument();
      expect(screen.getByText('Password is required')).toBeInTheDocument();
    });
  });

  test('submits form with valid data', async () => {
    const handleSubmit = jest.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    fireEvent.change(screen.getByLabelText('Username'), {
      target: { value: 'testuser' },
    });
    fireEvent.change(screen.getByLabelText('Password'), {
      target: { value: 'password' },
    });
    fireEvent.click(screen.getByText('Login'));

    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        username: 'testuser',
        password: 'password',
      });
    });
  });
});

3. 异步测试

3.1 测试异步函数

JavaScript
// api.test.js
import { fetchUsers, fetchUser } from './api';

describe('API functions', () => {
  test('fetchUsers returns array of users', async () => {
    const users = await fetchUsers();
    expect(Array.isArray(users)).toBe(true);
    expect(users.length).toBeGreaterThan(0);
  });

  test('fetchUser returns user object', async () => {
    const user = await fetchUser(1);
    expect(user).toHaveProperty('id');
    expect(user).toHaveProperty('name');
    expect(user).toHaveProperty('email');
  });
});

3.2 Mock 异步请求

JavaScript
// api.test.js
import { fetchUsers } from './api';

// Mock fetch
global.fetch = jest.fn();

describe('fetchUsers with mock', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('fetches users from API', async () => {
    const mockUsers = [
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' },
    ];

    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUsers,
    });

    const users = await fetchUsers();
    expect(users).toEqual(mockUsers);
    expect(fetch).toHaveBeenCalledTimes(1);
  });

  test('handles API error', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));

    await expect(fetchUsers()).rejects.toThrow('Network error');
  });
});

4. Mock 和 Stub

4.1 Mock 函数

JavaScript
// 使用jest.fn()
const mockFn = jest.fn();  // const不可重新赋值;let块级作用域变量
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');

// Mock返回值
const mockFn = jest.fn().mockReturnValue('result');
expect(mockFn()).toBe('result');

// Mock异步返回值
const mockFn = jest.fn().mockResolvedValue('result');
await expect(mockFn()).resolves.toBe('result');

// Mock实现
const mockFn = jest.fn().mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);

4.2 Mock 模块

JavaScript
// Mock整个模块
jest.mock('./api', () => ({
  fetchUsers: jest.fn(() => Promise.resolve([])),  // Promise异步操作容器:pending→fulfilled/rejected
  fetchUser: jest.fn((id) => Promise.resolve({ id, name: 'Test' })),
}));

// Mock部分模块
jest.mock('./api', () => ({
  ...jest.requireActual('./api'),
  fetchUsers: jest.fn(() => Promise.resolve([])),
}));

⚡ Vitest 单元测试

推荐场景: Vite 项目首选 Vitest ,与 Jest API 高度兼容,无需额外配置即可 TypeScript/ESM 友好。

1. Vitest 基础

1.1 基本配置

TypeScript
// vite.config.ts(在 Vite 配置中集成 Vitest)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // 测试环境(浏览器 API 模拟)
    environment: 'jsdom',
    // 全局 API(无需在每个文件 import describe/it/expect)
    globals: true,
    // 测试前执行的设置文件
    setupFiles: ['./src/test/setup.ts'],
    // 覆盖率配置
    coverage: {
      provider: 'v8',           // 使用 V8 内置覆盖率(比 istanbul 更快)
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
      },
    },
  },
});

// src/test/setup.ts
import '@testing-library/jest-dom'; // 扩展 expect 断言(toBeInTheDocument 等)

1.2 基本测试

TypeScript
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide } from './math';

describe('Math utils', () => {
  it('adds two numbers', () => {
    expect(add(1, 2)).toBe(3);
  });

  it('throws on divide by zero', () => {
    expect(() => divide(1, 0)).toThrow('Cannot divide by zero');
  });
});

2. React 组件测试( Vitest + Testing Library )

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

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick handler', () => {
    const handleClick = vi.fn(); // vi.fn() 等同于 jest.fn()
    render(<Button onClick={handleClick}>Submit</Button>);
    fireEvent.click(screen.getByText('Submit'));
    expect(handleClick).toHaveBeenCalledOnce();
  });

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

3. Mock 与 Spy

TypeScript
import { vi, describe, it, expect, beforeEach } from 'vitest';

// Mock 模块(与 jest.mock 语法相同)
vi.mock('./api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));

// 监听函数调用(不改变实现)
const spy = vi.spyOn(console, 'log');
console.log('test');
expect(spy).toHaveBeenCalledWith('test');

// 定时器控制
vi.useFakeTimers();
setTimeout(() => { /* ... */ }, 1000);
vi.advanceTimersByTime(1000); // 快进 1 秒
vi.useRealTimers(); // 恢复真实定时器

// 快照测试(自动生成并比对 DOM 快照)
import { render } from '@testing-library/react';
it('matches snapshot', () => {
  const { container } = render(<MyComponent />);
  expect(container).toMatchInlineSnapshot();
});

4. Vitest 与 Jest 对比

特性 Vitest Jest
原生 ESM 支持 ✅ 原生 ⚠️ 需配置
TypeScript ✅ 零配置 ⚠️ 需 ts-jest
Vite 配置复用 ✅ 自动 ❌ 独立配置
热模块重载 ✅ Watch 模式极快 ⚠️ 较慢
API 兼容性 与 Jest 高度兼容 原版
并行执行 ✅ 默认并行 ✅ 需配置

🌐 Cypress E2E 测试

1. Cypress 基础

1.1 基本配置

JavaScript
// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false,
    screenshotOnRunFailure: true,
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
  },
});

1.2 基本测试

JavaScript
// cypress/e2e/login.cy.js
describe('Login Flow', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('should login with valid credentials', () => {
    cy.get('[data-testid="username"]').type('testuser');
    cy.get('[data-testid="password"]').type('password');
    cy.get('[data-testid="submit"]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome, testuser').should('be.visible');
  });

  it('should show error with invalid credentials', () => {
    cy.get('[data-testid="username"]').type('invalid');
    cy.get('[data-testid="password"]').type('invalid');
    cy.get('[data-testid="submit"]').click();
    cy.contains('Invalid credentials').should('be.visible');
  });
});

2. 高级用法

2.1 自定义命令

JavaScript
// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
  cy.get('[data-testid="username"]').type(username);
  cy.get('[data-testid="password"]').type(password);
  cy.get('[data-testid="submit"]').click();
});

// 使用
cy.login('testuser', 'password');

2.2 API 测试

JavaScript
describe('API Testing', () => {
  it('should fetch users', () => {
    cy.request('/api/users').then((response) => {
      expect(response.status).to.eq(200);
      expect(response.body).to.be.an('array');
    });
  });

  it('should create user', () => {
    cy.request('POST', '/api/users', {
      name: 'Test User',
      email: 'test@example.com',
    }).then((response) => {
      expect(response.status).to.eq(201);
      expect(response.body).to.have.property('id');
    });
  });
});

🎭 Playwright 自动化测试

1. Playwright 基础

1.1 基本配置

JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');  // 解构赋值:从对象/数组提取值

module.exports = defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },  // ...展开运算符:展开数组/对象
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

1.2 基本测试

JavaScript
// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login Flow', () => {
  test('should login with valid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="username"]', 'testuser');
    await page.fill('[data-testid="password"]', 'password');
    await page.click('[data-testid="submit"]');
    await expect(page).toHaveURL(/.*dashboard/);
    await expect(page.locator('text=Welcome, testuser')).toBeVisible();
  });

  test('should show error with invalid credentials', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[data-testid="username"]', 'invalid');
    await page.fill('[data-testid="password"]', 'invalid');
    await page.click('[data-testid="submit"]');
    await expect(page.locator('text=Invalid credentials')).toBeVisible();
  });
});

2. 高级用法

2.1 多浏览器测试

JavaScript
test.describe('Cross-browser testing', () => {
  test('should work on all browsers', async ({ page, browserName }) => {
    await page.goto('/');
    await expect(page.locator('h1')).toHaveText('Welcome');
    console.log(`Testing on ${browserName}`);
  });
});

2.2 网络拦截

JavaScript
test('should intercept API requests', async ({ page }) => {  // async定义异步函数;await等待Promise完成
  await page.route('**/api/users', route => {  // await等待异步操作完成
    route.fulfill({
      status: 200,
      body: JSON.stringify([{ id: 1, name: 'Test User' }]),
    });
  });

  await page.goto('/users');
  await expect(page.locator('text=Test User')).toBeVisible();
});

📊 测试覆盖率

1. Jest 覆盖率

JavaScript
// jest.config.js
module.exports = {
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/main.{js,jsx,ts,tsx}',
  ],
  coverageReporters: ['text', 'lcov', 'html'],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

// 运行测试并生成覆盖率
npm test -- --coverage

2. Istanbul 覆盖率

JavaScript
// 使用nyc生成覆盖率
npm install -D nyc

// package.json
{
  "scripts": {
    "test:coverage": "nyc npm test"
  }
}

// nyc配置
{
  "reporter": ["text", "html", "lcov"],
  "include": ["src/**/*.js"],
  "exclude": ["src/**/*.test.js"]
}

📝 练习题

1. 基础题

题目 1 :编写一个单元测试

JavaScript
// math.js
export function add(a, b) {
  return a + b;
}

// math.test.js
test('adds 1 + 2 to equal 3', () => {
  // 编写测试
});

2. 进阶题

题目 2 :编写一个 React 组件测试

JavaScript
// Button.js
export function Button({ children, onClick }) {
  return <button onClick={onClick}>{children}</button>;
}

// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';

test('calls onClick when clicked', () => {  // 箭头函数:简洁的函数语法
  // 编写测试
});

3. 面试题

题目 3 :解释单元测试、集成测试和 E2E 测试的区别

JavaScript
// 答案要点:
// 1. 单元测试:测试独立的功能单元,速度快,隔离性好
// 2. 集成测试:测试多个组件的交互,速度中等,测试集成点
// 3. E2E测试:测试完整的用户流程,速度慢,测试真实场景
// 4. 测试金字塔:大量单元测试,适量集成测试,少量E2E测试

🎯 本章总结

本章节全面介绍了前端测试的各种技术和工具,包括 Jest 、 Cypress 、 Playwright 、测试覆盖率等。关键要点:

  1. 测试类型:理解单元测试、集成测试、 E2E 测试的区别
  2. Jest:掌握 Jest 单元测试和 React 组件测试
  3. Cypress:掌握 Cypress E2E 测试
  4. Playwright:掌握 Playwright 自动化测试
  5. 测试覆盖率:掌握测试覆盖率分析
  6. 最佳实践:理解测试金字塔和测试最佳实践

下一步将深入学习前端监控技术。