跳转至

前端测试

前端测试

📚 章节目标

本章节将全面介绍前端测试的各种技术和工具,包括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([])),
}));

🌐 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. 最佳实践:理解测试金字塔和测试最佳实践

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