Skip to content

前端测试 ​

前端测试是保证代码质量的重要手段,包括单元测试、集成测试、E2E 测试等多个层次。

测试金字塔 ​

         /\
        /  \        E2E 测试(端到端)
       /    \       - 模拟真实用户操作
      /------\      - 数量最少,成本最高
     /        \
    /  集成测试  \   集成测试
   /            \   - 测试模块间协作
  /--------------\  - 数量适中
 /                \
/    单元测试      \ 单元测试
--------------------  - 测试单个函数/组件
                      - 数量最多,成本最低

测试类型对比 ​

类型范围速度成本工具
单元测试函数/组件快低Jest, Vitest
集成测试模块交互中中Jest, Vitest
E2E 测试完整流程慢高Playwright, Cypress

Jest ​

Jest 是 Facebook 开发的 JavaScript 测试框架,功能全面,开箱即用。

安装配置 ​

bash
npm install -D jest @types/jest
# React 项目
npm install -D @testing-library/react @testing-library/jest-dom
# TypeScript 支持
npm install -D ts-jest
javascript
// jest.config.js
module.exports = {
  // 测试环境
  testEnvironment: 'jsdom',  // 浏览器环境,或 'node'

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

  // 模块路径映射
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss)$': 'identity-obj-proxy'  // 样式文件 mock
  },

  // TypeScript 转换
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  },

  // 覆盖率配置
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  },

  // 每个测试文件前执行
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
};
javascript
// jest.setup.js
import '@testing-library/jest-dom';

基础语法 ​

javascript
// sum.js
export function sum(a, b) {
  return a + b;
}

// sum.test.js
import { sum } from './sum';

// 描述测试套件
describe('sum 函数', () => {
  // 单个测试用例
  test('1 + 2 应该等于 3', () => {
    expect(sum(1, 2)).toBe(3);
  });

  // it 是 test 的别名
  it('应该正确处理负数', () => {
    expect(sum(-1, -2)).toBe(-3);
  });

  // 跳过测试
  test.skip('跳过这个测试', () => {
    // ...
  });

  // 只运行这个测试
  test.only('只运行这个测试', () => {
    // ...
  });
});

常用断言(Matchers) ​

javascript
// 相等性
expect(value).toBe(3);              // 严格相等 ===
expect(value).toEqual({ a: 1 });    // 深度相等(对象/数组)
expect(value).toStrictEqual(obj);   // 更严格的深度相等

// 真值判断
expect(value).toBeTruthy();         // 真值
expect(value).toBeFalsy();          // 假值
expect(value).toBeNull();           // null
expect(value).toBeUndefined();      // undefined
expect(value).toBeDefined();        // 非 undefined

// 数字比较
expect(value).toBeGreaterThan(3);        // > 3
expect(value).toBeGreaterThanOrEqual(3); // >= 3
expect(value).toBeLessThan(3);           // < 3
expect(value).toBeCloseTo(0.3, 5);       // 浮点数近似相等

// 字符串匹配
expect(str).toMatch(/pattern/);     // 正则匹配
expect(str).toContain('substring'); // 包含子串

// 数组/可迭代对象
expect(arr).toContain(item);        // 包含元素
expect(arr).toHaveLength(3);        // 长度
expect(arr).toContainEqual({ a: 1 }); // 包含对象

// 对象
expect(obj).toHaveProperty('key');           // 有属性
expect(obj).toHaveProperty('key', 'value');  // 属性值
expect(obj).toMatchObject({ a: 1 });         // 部分匹配

// 异常
expect(() => fn()).toThrow();                // 抛出异常
expect(() => fn()).toThrow('error message'); // 抛出特定异常
expect(() => fn()).toThrow(CustomError);     // 抛出特定类型

// 取反
expect(value).not.toBe(3);

异步测试 ​

javascript
// 返回 Promise
test('async/await', async () => {
  const data = await fetchData();
  expect(data).toBe('data');
});

// 使用 resolves/rejects
test('resolves', () => {
  return expect(Promise.resolve('data')).resolves.toBe('data');
});

test('rejects', () => {
  return expect(Promise.reject('error')).rejects.toMatch('error');
});

// 回调函数(使用 done)
test('callback', (done) => {
  function callback(data) {
    try {
      expect(data).toBe('data');
      done();
    } catch (error) {
      done(error);
    }
  }
  fetchDataWithCallback(callback);
});

// 定时器测试
jest.useFakeTimers();

test('定时器', () => {
  const callback = jest.fn();
  setTimeout(callback, 1000);

  expect(callback).not.toBeCalled();

  jest.advanceTimersByTime(1000);  // 快进 1 秒
  // 或 jest.runAllTimers();       // 执行所有定时器

  expect(callback).toBeCalled();
});

Mock 函数 ​

javascript
// 创建 mock 函数
const mockFn = jest.fn();
mockFn('arg1', 'arg2');

// 断言调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg1', 'arg2');

// 设置返回值
const mockFn = jest.fn()
  .mockReturnValue(10)                     // 固定返回值
  .mockReturnValueOnce(20)                 // 第一次返回
  .mockResolvedValue('data')               // 返回 resolved Promise
  .mockRejectedValue(new Error('error'))   // 返回 rejected Promise
  .mockImplementation((x) => x * 2);       // 自定义实现

// Mock 模块
jest.mock('./api', () => ({
  fetchUser: jest.fn().mockResolvedValue({ name: 'John' })
}));

// 部分 mock
jest.mock('./utils', () => ({
  ...jest.requireActual('./utils'),
  formatDate: jest.fn()
}));

// Spy(监听真实函数)
const spy = jest.spyOn(object, 'method');
spy.mockImplementation(() => 'mocked');
// 测试后恢复
spy.mockRestore();

生命周期钩子 ​

javascript
describe('测试套件', () => {
  // 所有测试前执行一次
  beforeAll(() => {
    // 初始化数据库连接等
  });

  // 所有测试后执行一次
  afterAll(() => {
    // 关闭连接等
  });

  // 每个测试前执行
  beforeEach(() => {
    // 重置状态
    jest.clearAllMocks();
  });

  // 每个测试后执行
  afterEach(() => {
    // 清理
  });

  test('test 1', () => {});
  test('test 2', () => {});
});

Vitest ​

Vitest 是基于 Vite 的测试框架,与 Jest 兼容,但更快、配置更简单。

安装配置 ​

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom
javascript
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // 使用 jsdom 环境
    environment: 'jsdom',
    // 全局 API(不需要每次 import)
    globals: true,
    // 每个测试文件前执行
    setupFiles: './src/test/setup.ts',
    // 覆盖率
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html']
    }
  }
});
typescript
// src/test/setup.ts
import '@testing-library/jest-dom';

基本用法 ​

typescript
// 与 Jest 语法几乎相同
import { describe, it, expect, vi } from 'vitest';

describe('math', () => {
  it('should add numbers', () => {
    expect(1 + 1).toBe(2);
  });
});

// Mock 函数使用 vi 而非 jest
const mockFn = vi.fn();
vi.spyOn(object, 'method');
vi.mock('./module');

// 定时器
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
vi.useRealTimers();

Vitest vs Jest ​

特性VitestJest
速度更快(原生 ESM)较慢
配置复用 Vite 配置独立配置
HMR支持不支持
语法兼容 Jest-
生态较新成熟

React Testing Library ​

React Testing Library 专注于测试用户行为而非实现细节。

核心理念 ​

"The more your tests resemble the way your software is used,
the more confidence they can give you."

测试越接近用户使用软件的方式,就越能给你信心。

基本用法 ​

jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  test('renders initial count', () => {
    render(<Counter initialCount={0} />);

    // 查询元素
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  test('increments count on button click', async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={0} />);

    // 点击按钮
    await user.click(screen.getByRole('button', { name: /increment/i }));

    // 断言结果
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });

  test('input change', async () => {
    const user = userEvent.setup();
    render(<Form />);

    const input = screen.getByLabelText('Username');
    await user.type(input, 'john');

    expect(input).toHaveValue('john');
  });
});

查询方法 ​

jsx
// 查询类型
// getBy*    - 找到返回元素,找不到抛错
// queryBy*  - 找到返回元素,找不到返回 null
// findBy*   - 返回 Promise,适合异步元素

// getAllBy*, queryAllBy*, findAllBy* - 返回数组

// 查询方式(推荐顺序)
screen.getByRole('button', { name: /submit/i });  // ⭐ 首选,无障碍
screen.getByLabelText('Email');                    // 表单元素
screen.getByPlaceholderText('Enter email');        // placeholder
screen.getByText('Hello World');                   // 文本内容
screen.getByDisplayValue('current value');         // 表单当前值
screen.getByAltText('profile');                    // img alt
screen.getByTitle('tooltip');                      // title 属性
screen.getByTestId('custom-element');              // data-testid(最后手段)

// 示例
const button = screen.getByRole('button', { name: /submit/i });
const heading = screen.getByRole('heading', { level: 1 });
const checkbox = screen.getByRole('checkbox', { checked: true });
const link = screen.getByRole('link', { name: 'Home' });
const textbox = screen.getByRole('textbox', { name: /email/i });
const list = screen.getByRole('list');
const listItems = screen.getAllByRole('listitem');

用户交互 ​

jsx
import userEvent from '@testing-library/user-event';

test('user interactions', async () => {
  const user = userEvent.setup();
  render(<MyComponent />);

  // 点击
  await user.click(element);
  await user.dblClick(element);
  await user.tripleClick(element);

  // 输入
  await user.type(input, 'Hello');
  await user.clear(input);

  // 选择
  await user.selectOptions(select, ['option1', 'option2']);
  await user.deselectOptions(select, 'option1');

  // 键盘
  await user.keyboard('{Enter}');
  await user.keyboard('{Shift>}A{/Shift}');  // Shift+A

  // 复制粘贴
  await user.copy();
  await user.paste();

  // 悬停
  await user.hover(element);
  await user.unhover(element);

  // Tab 导航
  await user.tab();
  await user.tab({ shift: true });  // Shift+Tab

  // 上传文件
  const file = new File(['content'], 'test.png', { type: 'image/png' });
  await user.upload(input, file);
});

异步测试 ​

jsx
import { render, screen, waitFor } from '@testing-library/react';

test('async data loading', async () => {
  render(<UserProfile userId="1" />);

  // 等待元素出现
  const userName = await screen.findByText('John Doe');
  expect(userName).toBeInTheDocument();

  // 或使用 waitFor
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });

  // 等待元素消失
  await waitFor(() => {
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
  });
});

// 配置超时
await waitFor(() => {}, { timeout: 3000 });
await screen.findByText('text', {}, { timeout: 3000 });

Mock 网络请求 ​

jsx
// 使用 MSW (Mock Service Worker)
import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/user', (req, res, ctx) => {
    return res(ctx.json({ name: 'John' }));
  }),
  rest.post('/api/login', async (req, res, ctx) => {
    const { username } = await req.json();
    return res(ctx.json({ token: 'abc123' }));
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('fetches user data', async () => {
  render(<UserProfile />);
  expect(await screen.findByText('John')).toBeInTheDocument();
});

// 测试错误场景
test('handles server error', async () => {
  server.use(
    rest.get('/api/user', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );

  render(<UserProfile />);
  expect(await screen.findByText('Error loading user')).toBeInTheDocument();
});

Vue Testing ​

Vue Test Utils ​

bash
npm install -D @vue/test-utils vitest jsdom
javascript
// vite.config.js
export default defineConfig({
  test: {
    environment: 'jsdom'
  }
});
vue
<!-- Counter.vue -->
<template>
  <div>
    <p data-testid="count">{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);
const increment = () => count.value++;
</script>
javascript
// Counter.test.js
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.vue';

describe('Counter', () => {
  it('renders initial count', () => {
    const wrapper = mount(Counter);
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
  });

  it('increments count when button clicked', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('button').trigger('click');
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
  });

  it('receives props', () => {
    const wrapper = mount(Counter, {
      props: {
        initialCount: 10
      }
    });
    expect(wrapper.vm.count).toBe(10);
  });

  it('emits events', async () => {
    const wrapper = mount(Counter);
    await wrapper.find('button').trigger('click');
    expect(wrapper.emitted('update')).toBeTruthy();
    expect(wrapper.emitted('update')[0]).toEqual([1]);
  });
});

测试 Pinia Store ​

javascript
import { setActivePinia, createPinia } from 'pinia';
import { useCounterStore } from './counter';

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it('increments count', () => {
    const store = useCounterStore();
    expect(store.count).toBe(0);
    store.increment();
    expect(store.count).toBe(1);
  });

  it('computes double count', () => {
    const store = useCounterStore();
    store.count = 5;
    expect(store.doubleCount).toBe(10);
  });
});

E2E 测试 ​

Playwright ​

bash
npm init playwright@latest
javascript
// playwright.config.js
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  use: {
    baseURL: 'http://localhost:3000',
    headless: true,
    screenshot: 'only-on-failure',
    video: 'retain-on-failure'
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
    { name: 'webkit', use: { browserName: 'webkit' } }
  ],
  webServer: {
    command: 'npm run dev',
    port: 3000
  }
});
javascript
// e2e/login.spec.js
import { test, expect } from '@playwright/test';

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

  test('should login successfully', 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 page.waitForURL('/dashboard');

    // 断言
    await expect(page.locator('h1')).toHaveText('Welcome');
  });

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

    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toHaveText('Invalid credentials');
  });
});

Playwright 常用 API:

javascript
// 导航
await page.goto('https://example.com');
await page.goBack();
await page.goForward();
await page.reload();

// 选择器
page.locator('button');                    // CSS 选择器
page.locator('text=Submit');               // 文本
page.locator('[data-testid="submit"]');    // data-testid
page.getByRole('button', { name: 'Submit' }); // 角色
page.getByLabel('Email');                  // 标签
page.getByPlaceholder('Enter email');      // placeholder
page.getByText('Hello');                   // 文本

// 操作
await page.click('button');
await page.fill('input', 'text');
await page.type('input', 'text');  // 逐字符输入
await page.press('input', 'Enter');
await page.selectOption('select', 'value');
await page.check('input[type="checkbox"]');
await page.hover('button');

// 等待
await page.waitForSelector('.loaded');
await page.waitForURL('/dashboard');
await page.waitForResponse('/api/data');
await page.waitForTimeout(1000);  // 不推荐

// 断言
await expect(page).toHaveTitle('My App');
await expect(page).toHaveURL('/dashboard');
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toHaveText('Hello');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveCount(5);

// 截图
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ fullPage: true });

// 网络拦截
await page.route('/api/**', (route) => {
  route.fulfill({
    status: 200,
    body: JSON.stringify({ data: 'mocked' })
  });
});

Cypress ​

bash
npm install -D cypress
javascript
// cypress.config.js
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    supportFile: 'cypress/support/e2e.js'
  }
});
javascript
// cypress/e2e/login.cy.js
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('should login successfully', () => {
    cy.get('[data-testid="email"]').type('user@example.com');
    cy.get('[data-testid="password"]').type('password123');
    cy.get('[data-testid="login-button"]').click();

    cy.url().should('include', '/dashboard');
    cy.get('h1').should('contain', 'Welcome');
  });

  it('should show validation error', () => {
    cy.get('[data-testid="login-button"]').click();
    cy.get('.error').should('be.visible');
  });
});

// 网络请求拦截
cy.intercept('POST', '/api/login', {
  statusCode: 200,
  body: { token: 'abc123' }
}).as('loginRequest');

cy.get('button').click();
cy.wait('@loginRequest');

测试覆盖率 ​

bash
# Jest
npx jest --coverage

# Vitest
npx vitest --coverage
javascript
// 覆盖率报告解读
// - Statements: 语句覆盖率
// - Branches: 分支覆盖率(if/else, switch)
// - Functions: 函数覆盖率
// - Lines: 行覆盖率

// 覆盖率目标建议
// - 核心业务逻辑: 90%+
// - 工具函数: 100%
// - UI 组件: 70-80%
// - 整体项目: 80%+

常见面试题 ​

1. 单元测试、集成测试、E2E 测试的区别?

一句话答案: 单元测试测试最小单元(函数/组件),集成测试测试模块间协作,E2E 测试模拟真实用户操作测试完整流程。

详细解答:

特性单元测试集成测试E2E 测试
范围单个函数/组件多个模块整个应用
速度毫秒级秒级分钟级
依赖Mock 外部依赖部分真实依赖真实环境
发现问题逻辑错误集成错误用户体验问题
维护成本低中高

2. TDD 和 BDD 的区别?

TDD (Test-Driven Development):

  • 先写测试,再写代码
  • 红 → 绿 → 重构循环
  • 关注代码实现
javascript
// TDD 风格
test('should return sum of two numbers', () => {
  expect(add(1, 2)).toBe(3);
});

BDD (Behavior-Driven Development):

  • 关注行为和用户需求
  • 使用自然语言描述
  • Given-When-Then 结构
javascript
// BDD 风格
describe('Calculator', () => {
  describe('when adding two numbers', () => {
    it('should return the sum', () => {
      // Given
      const a = 1, b = 2;
      // When
      const result = add(a, b);
      // Then
      expect(result).toBe(3);
    });
  });
});

3. Mock、Stub、Spy 的区别?
javascript
// Spy - 监听真实函数,记录调用信息
const spy = jest.spyOn(object, 'method');
object.method();  // 执行真实函数
expect(spy).toHaveBeenCalled();

// Stub - 替换函数实现
const stub = jest.fn().mockReturnValue(42);
// 不执行真实函数,直接返回指定值

// Mock - 模拟整个模块
jest.mock('./api', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'mocked' })
}));

4. 如何测试异步代码?
javascript
// 1. async/await
test('async', async () => {
  const data = await fetchData();
  expect(data).toBe('data');
});

// 2. Promise
test('promise', () => {
  return expect(fetchData()).resolves.toBe('data');
});

// 3. done 回调
test('callback', (done) => {
  fetchData((data) => {
    expect(data).toBe('data');
    done();
  });
});

// 4. fake timers
jest.useFakeTimers();
test('timer', () => {
  const callback = jest.fn();
  setTimeout(callback, 1000);
  jest.advanceTimersByTime(1000);
  expect(callback).toHaveBeenCalled();
});

5. 如何提高测试质量?
  1. 遵循 AAA 模式:Arrange(准备)→ Act(执行)→ Assert(断言)
  2. 测试行为而非实现:关注输入输出,不关注内部实现
  3. 保持测试独立:每个测试互不影响
  4. 使用有意义的命名:描述清楚测试场景
  5. 避免测试实现细节:不测试私有方法
  6. 合理使用 Mock:只 Mock 外部依赖
  7. 保持测试简单:一个测试只测一件事
  8. 定期维护测试:删除过时测试,更新断言
javascript
// 好的测试
test('should display error when form submitted with empty email', async () => {
  // Arrange
  render(<LoginForm />);

  // Act
  await userEvent.click(screen.getByRole('button', { name: /submit/i }));

  // Assert
  expect(screen.getByText('Email is required')).toBeInTheDocument();
});

// 不好的测试
test('test1', () => {
  const wrapper = shallow(<LoginForm />);
  wrapper.setState({ email: '' });  // 测试实现细节
  expect(wrapper.instance().validateEmail()).toBe(false);  // 测试私有方法
});