跳到主要内容

测试策略

介绍

前端测试是确保应用质量、稳定性和可靠性的关键环节。一个完善的测试策略可以帮助开发团队及早发现并解决问题,减少bug,提高代码质量,加速开发流程。本章将介绍前端测试的基本概念、核心原理和常用技术。

核心概念与原理

测试的目标

  1. 确保功能正确性:验证应用是否符合需求规格,确保功能按预期工作
  2. 提高代码质量:通过测试发现并修复代码问题,减少技术债务
  3. 减少回归bug:确保新代码不会破坏已有功能,维持系统稳定性
  4. 提高开发效率:自动化测试减少手动测试工作量,加速开发和发布周期
  5. 增强自信心:确保应用在发布前达到预期质量,团队可以自信地部署新功能
  6. 便于重构:测试用例提供安全网,支持代码重构和系统演进
  7. 文档化行为:测试用例作为系统行为的活文档,帮助新开发者理解系统
  8. 促进设计:测试驱动开发促使开发者思考接口设计和代码结构

测试原则

  1. 早测试,常测试:越早发现问题,修复成本越低
  2. 测试隔离:测试应该相互独立,不依赖于其他测试的执行顺序或结果
  3. 测试覆盖:测试应覆盖关键功能路径和边界条件
  4. 测试可重复:测试应该是确定性的,多次运行得到相同结果
  5. 测试速度:测试应该快速执行,以便频繁运行
  6. 测试维护:测试代码应该像产品代码一样维护和重构
  7. 测试自动化:尽可能自动化测试过程,减少人工干预
  8. 测试价值:测试的价值应大于编写和维护测试的成本

测试类型

按测试范围分类

  1. 单元测试:测试单个组件或函数的最小单元

    • 特点:快速、隔离、精确定位问题
    • 工具:Jest, Mocha, Jasmine
    • 示例:测试一个格式化日期的函数
  2. 集成测试:测试多个组件或模块的交互

    • 特点:验证组件间协作,发现接口问题
    • 工具:React Testing Library, Enzyme
    • 示例:测试表单提交后数据处理流程
  3. 端到端测试:测试整个应用的流程,模拟用户操作

    • 特点:验证完整用户场景,最接近真实使用
    • 工具:Cypress, Playwright, Selenium
    • 示例:测试用户登录、购物和结账流程

按测试目的分类

  1. 功能测试:验证功能是否符合需求

    • 特点:关注功能正确性,不关注实现细节
    • 工具:Jest, Cypress
    • 示例:验证搜索功能返回正确结果
  2. 快照测试:比较组件渲染结果与预期快照

    • 特点:快速发现UI变化,防止意外修改
    • 工具:Jest Snapshot, Storybook
    • 示例:验证按钮组件在不同状态下的渲染一致性
  3. 性能测试:测试应用的性能表现

    • 特点:关注加载时间、响应速度、资源使用
    • 工具:Lighthouse, WebPageTest
    • 示例:测量首次内容绘制时间、交互到响应时间
  4. 可访问性测试:测试应用的可访问性

    • 特点:确保所有用户都能使用应用
    • 工具:axe-core, Lighthouse
    • 示例:检查颜色对比度、键盘导航、屏幕阅读器兼容性
  5. 安全测试:测试应用的安全性

    • 特点:发现安全漏洞和风险
    • 工具:OWASP ZAP, npm audit
    • 示例:检查XSS漏洞、CSRF保护、依赖安全
  6. 视觉回归测试:测试UI视觉变化

    • 特点:捕捉意外的视觉变化
    • 工具:Percy, Applitools
    • 示例:比较UI组件在不同浏览器中的渲染一致性
  7. 兼容性测试:测试在不同环境下的兼容性

    • 特点:确保在各种浏览器和设备上正常工作
    • 工具:BrowserStack, Sauce Labs
    • 示例:验证应用在IE11、Safari和移动设备上的表现

测试策略模型

测试金字塔

测试金字塔是由Mike Cohn提出的测试策略模型,它建议:

            /\      端到端测试 (10%)
/ \ (慢、脆弱、高成本)
/----\
/ \ 集成测试 (20%)
/ \ (中等速度和成本)
/----------\
/ \ 单元测试 (70%)
/______________\(快速、稳定、低成本)
  • 底层:大量的单元测试

    • 特点:快速执行(毫秒级)、稳定可靠、低维护成本
    • 数量:占总测试数量的70%左右
    • 目标:验证小型代码单元的正确性
  • 中层:适量的集成测试

    • 特点:中等执行速度(秒级)、中等稳定性和维护成本
    • 数量:占总测试数量的20%左右
    • 目标:验证组件间交互和接口正确性
  • 顶层:少量的端到端测试

    • 特点:执行慢(分钟级)、较脆弱、维护成本高
    • 数量:占总测试数量的10%左右
    • 目标:验证关键用户流程和场景

测试奖杯

测试奖杯是Kent C. Dodds提出的现代前端测试策略模型,更适合现代前端应用:

     ___________     端到端测试 (10%)
/ \ (验证关键流程)
/ \
/_______________\ 集成测试 (40%)
\ / (组件交互和用户行为)
\ /
\___________/ 单元测试 (30%)
| | (纯逻辑函数)
|___________| 静态测试 (20%)
(类型检查、lint)
  • 底部:静态测试(类型检查、代码分析)

    • 特点:即时反馈、零运行成本
    • 工具:TypeScript, ESLint
    • 目标:捕获语法错误、类型错误和代码风格问题
  • 下层:单元测试(纯函数和工具函数)

    • 特点:快速、稳定、聚焦于纯逻辑
    • 目标:验证不涉及UI或副作用的纯逻辑
  • 中上层:集成测试(组件和用户交互)

    • 特点:测试组件在真实DOM环境中的行为
    • 目标:验证用户交互和组件协作
  • 顶层:端到端测试(关键用户流程)

    • 特点:在真实环境中测试完整流程
    • 目标:验证最重要的用户场景

测试四象限

Brian Marick提出的测试四象限模型,从业务价值和技术视角分类:

业务面向 |  Q2: 业务面向验收测试      | Q1: 业务面向单元测试
| - 功能测试 | - 单元测试
| - 故事测试 | - 组件测试
| - 原型测试 | - 集成测试
| 支持团队理解需求 | 支持团队构建产品
---------+---------------------------+-------------------------
| Q3: 技术面向批评产品 | Q4: 技术面向支持产品
| - 性能测试 | - 性能测试
| - 负载测试 | - 安全测试
| - 压力测试 | - 可访问性测试
| - 可用性测试 | - 兼容性测试
技术面向 | 批评产品 | 支持产品
  • Q1:业务面向、支持团队构建产品的测试

    • 特点:验证代码是否正确实现业务需求
    • 示例:单元测试、组件测试、集成测试
  • Q2:业务面向、支持团队理解需求的测试

    • 特点:验证产品是否满足用户需求
    • 示例:功能测试、用户验收测试、探索性测试
  • Q3:技术面向、批评产品的测试

    • 特点:发现产品在极端条件下的问题
    • 示例:性能测试、负载测试、压力测试
  • Q4:技术面向、支持产品的测试

    • 特点:验证产品的非功能性需求
    • 示例:安全测试、可访问性测试、兼容性测试

测试策略模型图示

+------------------+        +------------------+        +------------------+
| 测试类型 | | 测试金字塔 | | 测试工具分类 |
+------------------+ +------------------+ +------------------+
| - 单元测试 | | - 单元测试(70%) | | - 单元测试工具 |
| - 集成测试 | | - 集成测试(20%) | | - 集成测试工具 |
| - 端到端测试 | | - 端到端测试(10%)| | - 端到端测试工具 |
| - 快照测试 | | | | - 测试运行器 |
| - 性能测试 | | | | - 断言库 |
| - 可访问性测试 | | | | - 模拟库 |
| - 安全测试 | | | | - 覆盖率工具 |
| - 视觉回归测试 | | | | |
+------------------+ +------------------+ +------------------+

测试技术示例

单元测试

Jest单元测试示例

// 被测试函数
function sum(a, b) {
return a + b;
}

// 测试用例
describe('sum function', () => {
test('adds 1 + 2 to equal 3', () => {
expect(sum(1, 2)).toBe(3);
});

test('adds negative numbers correctly', () => {
expect(sum(-1, -2)).toBe(-3);
});

test('adds zero correctly', () => {
expect(sum(5, 0)).toBe(5);
});

// 测试边界条件
test('handles large numbers', () => {
expect(sum(Number.MAX_SAFE_INTEGER, 1)).toBe(Number.MAX_SAFE_INTEGER + 1);
});

// 使用test.each进行参数化测试
test.each([
[1, 1, 2],
[2, 2, 4],
[0, 5, 5],
[-1, 1, 0]
])('adds %i + %i to equal %i', (a, b, expected) => {
expect(sum(a, b)).toBe(expected);
});
});

使用Vitest的单元测试示例

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

// sum.test.js
import { describe, it, expect } from 'vitest';
import { sum } from './sum';

describe('sum function', () => {
it('adds two numbers correctly', () => {
expect(sum(1, 2)).toBe(3);
});

// 使用Vitest的内联快照
it('returns the correct result for various inputs', () => {
const results = [
sum(1, 2),
sum(-1, 1),
sum(0, 0)
];
expect(results).toMatchInlineSnapshot(`
[
3,
0,
0,
]
`);
});
});

React组件单元测试

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

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

test('calls onClick handler when clicked', async () => {
const onClick = jest.fn();
render(<Button text="Click me" onClick={onClick} />);

// 使用userEvent模拟更真实的用户交互
const user = userEvent.setup();
await user.click(screen.getByText(/click me/i));

expect(onClick).toHaveBeenCalledTimes(1);
});

test('applies correct styles based on props', () => {
const { rerender } = render(<Button text="Default" />);
expect(screen.getByRole('button')).not.toHaveClass('primary');

// 重新渲染组件测试不同props
rerender(<Button text="Primary" primary />);
expect(screen.getByRole('button')).toHaveClass('primary');
});

test('is accessible', () => {
render(<Button text="Accessible Button" aria-label="Important action" />);
expect(screen.getByLabelText('Important action')).toBeInTheDocument();
});
});

Vue组件单元测试

// Button.vue
<template>
<button :class="{ primary }" @click="$emit('click')">
{{ text }}
</button>
</template>

<script>
export default {
props: {
text: String,
primary: Boolean
}
};
</script>

// Button.spec.js
import { mount } from '@vue/test-utils';
import Button from './Button.vue';

describe('Button.vue', () => {
test('renders text correctly', () => {
const wrapper = mount(Button, {
props: { text: 'Click me' }
});
expect(wrapper.text()).toContain('Click me');
});

test('emits click event when clicked', async () => {
const wrapper = mount(Button, {
props: { text: 'Click me' }
});
await wrapper.trigger('click');
expect(wrapper.emitted().click).toBeTruthy();
expect(wrapper.emitted().click.length).toBe(1);
});

test('applies primary class when primary prop is true', () => {
const wrapper = mount(Button, {
props: { text: 'Primary', primary: true }
});
expect(wrapper.classes()).toContain('primary');
});
});

集成测试

React集成测试示例

import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';

// 模拟API调用
jest.mock('../api/todos', () => ({
saveTodo: jest.fn().mockResolvedValue({ id: 'new-id', completed: false }),
updateTodo: jest.fn().mockResolvedValue({ success: true }),
deleteTodo: jest.fn().mockResolvedValue({ success: true })
}));

describe('TodoList component', () => {
beforeEach(() => {
// 重置模拟函数
jest.clearAllMocks();
});

test('adds new todo when form is submitted', async () => {
render(<TodoList />);
const user = userEvent.setup();

// 输入新待办事项
await user.type(screen.getByPlaceholderText(/add todo/i), 'Learn testing');
await user.click(screen.getByText(/add/i));

// 验证待办事项已添加到列表
const todoElement = screen.getByText(/learn testing/i);
expect(todoElement).toBeInTheDocument();
});

test('toggles todo completion when clicked', async () => {
render(<TodoList initialTodos={[{ id: '1', text: 'Learn testing', completed: false }]} />);
const user = userEvent.setup();

// 点击待办事项切换完成状态
const todoElement = screen.getByText(/learn testing/i);
await user.click(todoElement);

// 验证完成状态已切换
expect(todoElement).toHaveClass('completed');
});

test('filters todos correctly', async () => {
render(
<TodoList
initialTodos={[
{ id: '1', text: 'Learn testing', completed: true },
{ id: '2', text: 'Write tests', completed: false }
]}
/>
);
const user = userEvent.setup();

// 初始状态应显示所有待办事项
expect(screen.getAllByRole('listitem')).toHaveLength(2);

// 切换到仅显示已完成项
await user.click(screen.getByText(/completed/i));
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(screen.getByText(/learn testing/i)).toBeInTheDocument();
expect(screen.queryByText(/write tests/i)).not.toBeInTheDocument();

// 切换到仅显示活动项
await user.click(screen.getByText(/active/i));
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(screen.queryByText(/learn testing/i)).not.toBeInTheDocument();
expect(screen.getByText(/write tests/i)).toBeInTheDocument();
});
});

使用MSW模拟API的集成测试

// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
rest.get('/api/todos', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: '1', text: 'Learn MSW', completed: false },
{ id: '2', text: 'Integrate with tests', completed: true }
])
);
}),

rest.post('/api/todos', (req, res, ctx) => {
const { text } = req.body;
return res(
ctx.status(201),
ctx.json({ id: Date.now().toString(), text, completed: false })
);
})
];

// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/components/TodoApp.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { server } from '../mocks/server';
import TodoApp from './TodoApp';

// 启动MSW服务器来拦截API请求
beforeAll(() => server.listen());
aftereEach(() => server.resetHandlers());
afterAll(() => server.close());

test('loads and displays todos from API', async () => {
render(<TodoApp />);

// 等待API加载完成
await waitFor(() => {
expect(screen.getByText(/learn msw/i)).toBeInTheDocument();
});

expect(screen.getByText(/integrate with tests/i)).toHaveClass('completed');
});

test('adds a new todo via API', async () => {
render(<TodoApp />);
const user = userEvent.setup();

// 等待初始加载
await waitFor(() => {
expect(screen.getByText(/learn msw/i)).toBeInTheDocument();
});

// 添加新待办事项
await user.type(screen.getByPlaceholderText(/add todo/i), 'Test with MSW');
await user.click(screen.getByText(/add/i));

// 验证新待办事项已添加
await waitFor(() => {
expect(screen.getByText(/test with msw/i)).toBeInTheDocument();
});
});

端到端测试

Cypress端到端测试示例

// cypress/e2e/todo.cy.js
describe('Todo App', () => {
beforeEach(() => {
// 访问应用前重置数据库状态
cy.request('POST', '/api/reset-db');
cy.visit('/');
});

it('displays welcome message', () => {
cy.contains('Welcome to Todo App');
});

it('adds new todo', () => {
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();
cy.get('[data-testid=todo-list]').should('contain', 'Learn Cypress');

// 验证计数器更新
cy.get('[data-testid=todo-count]').should('contain', '1 item left');
});

it('toggles todo completion', () => {
// 添加待办事项
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();

// 切换完成状态
cy.contains('Learn Cypress').click();
cy.contains('Learn Cypress').should('have.class', 'completed');

// 验证计数器更新
cy.get('[data-testid=todo-count]').should('contain', '0 items left');
});

it('deletes todo', () => {
// 添加待办事项
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();

// 删除待办事项
cy.contains('Learn Cypress').parent().find('[data-testid=delete-button]').click();
cy.get('[data-testid=todo-list]').should('not.contain', 'Learn Cypress');
});

it('filters todos correctly', () => {
// 添加两个待办事项
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();
cy.get('[data-testid=todo-input]').type('Write E2E tests');
cy.get('[data-testid=add-button]').click();

// 将第一个标记为已完成
cy.contains('Learn Cypress').click();

// 测试"Active"过滤器
cy.contains('Active').click();
cy.get('[data-testid=todo-list]').should('contain', 'Write E2E tests');
cy.get('[data-testid=todo-list]').should('not.contain', 'Learn Cypress');

// 测试"Completed"过滤器
cy.contains('Completed').click();
cy.get('[data-testid=todo-list]').should('contain', 'Learn Cypress');
cy.get('[data-testid=todo-list]').should('not.contain', 'Write E2E tests');

// 测试"All"过滤器
cy.contains('All').click();
cy.get('[data-testid=todo-list]').should('contain', 'Learn Cypress');
cy.get('[data-testid=todo-list]').should('contain', 'Write E2E tests');
});

it('persists todos after page reload', () => {
// 添加待办事项
cy.get('[data-testid=todo-input]').type('Learn Cypress');
cy.get('[data-testid=add-button]').click();

// 重新加载页面
cy.reload();

// 验证待办事项仍然存在
cy.get('[data-testid=todo-list]').should('contain', 'Learn Cypress');
});
});

Playwright端到端测试示例

// tests/todo.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Todo App', () => {
test.beforeEach(async ({ page, request }) => {
// 重置测试数据
await request.post('/api/reset-db');
await page.goto('/');
});

test('should display the app title', async ({ page }) => {
await expect(page.locator('h1')).toHaveText('Todo App');
});

test('should add a new todo', async ({ page }) => {
// 添加新待办事项
await page.fill('[data-testid=todo-input]', 'Learn Playwright');
await page.click('[data-testid=add-button]');

// 验证待办事项已添加
await expect(page.locator('[data-testid=todo-list]')).toContainText('Learn Playwright');
});

test('should toggle todo completion', async ({ page }) => {
// 添加待办事项
await page.fill('[data-testid=todo-input]', 'Learn Playwright');
await page.click('[data-testid=add-button]');

// 切换完成状态
await page.click('text=Learn Playwright');

// 验证完成状态
await expect(page.locator('text=Learn Playwright')).toHaveClass(/completed/);
});

test('should filter todos correctly', async ({ page }) => {
// 添加两个待办事项
await page.fill('[data-testid=todo-input]', 'Learn Playwright');
await page.click('[data-testid=add-button]');
await page.fill('[data-testid=todo-input]', 'Write tests');
await page.click('[data-testid=add-button]');

// 将第一个标记为已完成
await page.click('text=Learn Playwright');

// 测试"Active"过滤器
await page.click('text=Active');
await expect(page.locator('[data-testid=todo-list]')).toContainText('Write tests');
await expect(page.locator('[data-testid=todo-list]')).not.toContainText('Learn Playwright');

// 测试"Completed"过滤器
await page.click('text=Completed');
await expect(page.locator('[data-testid=todo-list]')).toContainText('Learn Playwright');
await expect(page.locator('[data-testid=todo-list]')).not.toContainText('Write tests');

// 测试"All"过滤器
await page.click('text=All');
await expect(page.locator('[data-testid=todo-list]')).toContainText('Learn Playwright');
await expect(page.locator('[data-testid=todo-list]')).toContainText('Write tests');
});

test('should take screenshots for visual comparison', async ({ page }) => {
// 添加待办事项
await page.fill('[data-testid=todo-input]', 'Learn Playwright');
await page.click('[data-testid=add-button]');

// 截图比较
await expect(page).toHaveScreenshot('todo-list.png');

// 切换完成状态后再次截图
await page.click('text=Learn Playwright');
await expect(page).toHaveScreenshot('todo-list-completed.png');
});
});

快照测试

Jest快照测试示例

import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';

describe('Button component snapshot', () => {
test('renders correctly', () => {
const { asFragment } = render(<Button text="Click me" />);
expect(asFragment()).toMatchSnapshot();
});

test('renders with primary style correctly', () => {
const { asFragment } = render(<Button text="Click me" primary />);
expect(asFragment()).toMatchSnapshot();
});

test('renders with different sizes correctly', () => {
// 使用内联快照进行简单比较
const { getByRole } = render(<Button text="Small" size="small" />);
expect(getByRole('button').className).toMatchInlineSnapshot(`"button small"`);

// 测试多个变体
const sizes = ['small', 'medium', 'large'];
sizes.forEach(size => {
const { getByRole } = render(<Button text={size} size={size} />);
expect(getByRole('button')).toHaveClass(size);
});
});
});

Storybook与Storyshots集成

// Button.stories.js
import React from 'react';
import Button from './Button';

export default {
title: 'Components/Button',
component: Button,
argTypes: {
size: {
control: { type: 'select', options: ['small', 'medium', 'large'] }
},
primary: {
control: 'boolean'
}
}
};

const Template = (args) => <Button {...args} />;

export const Default = Template.bind({});
Default.args = {
text: 'Button',
primary: false,
size: 'medium'
};

export const Primary = Template.bind({});
Primary.args = {
...Default.args,
text: 'Primary Button',
primary: true
};

export const Small = Template.bind({});
Small.args = {
...Default.args,
text: 'Small Button',
size: 'small'
};

export const Large = Template.bind({});
Large.args = {
...Default.args,
text: 'Large Button',
size: 'large'
};

// storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';

// 初始化Storyshots,自动为所有stories创建快照测试
initStoryshots();

性能测试

// performance.test.js
import { render } from '@testing-library/react';
import LargeList from './LargeList';

describe('Performance tests', () => {
test('renders large list efficiently', () => {
// 生成大量测试数据
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
text: `Item ${i}`
}));

// 测量渲染时间
const start = performance.now();
render(<LargeList items={items} />);
const end = performance.now();

// 验证渲染时间在可接受范围内
const renderTime = end - start;
console.log(`Render time: ${renderTime}ms`);
expect(renderTime).toBeLessThan(100); // 假设100ms是可接受的阈值
});
});

可访问性测试

// accessibility.test.js
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Form from './Form';

// 扩展Jest的匹配器
expect.extend(toHaveNoViolations);

describe('Form accessibility', () => {
test('should not have accessibility violations', async () => {
const { container } = render(
<Form>
<label htmlFor="name">Name</label>
<input id="name" type="text" />
<button type="submit">Submit</button>
</Form>
);

// 运行axe可访问性测试
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('form with accessibility issues', async () => {
// 故意创建有可访问性问题的表单
const { container } = render(
<Form>
<input type="text" /> {/* 缺少标签 */}
<button>Submit</button> {/* 缺少类型 */}
</Form>
);

// 运行axe可访问性测试并检查是否有预期的违规
const results = await axe(container);
// 我们期望有违规,但不在这里断言具体违规
// 因为这只是一个演示
console.log(results.violations);
});
});

解决方案

测试策略制定

  1. 确定测试目标:根据项目需求和规模确定测试目标
  2. 选择测试类型:根据测试目标选择合适的测试类型组合
  3. 测试覆盖范围:确定哪些功能需要测试,达到什么覆盖率
  4. 测试环境:设置开发、测试、 staging和生产环境
  5. 测试数据:准备测试数据,包括正常和异常情况
  6. 测试自动化:确定哪些测试可以自动化,哪些需要手动测试
  7. 测试时机:集成测试到开发流程中(如CI/CD)
  8. 缺陷管理:建立缺陷报告和跟踪机制
  9. 测试文档:编写和维护测试文档

测试流程

1. 测试规划与设计

  • 需求分析:理解功能需求和非功能需求,确定测试范围和优先级
  • 测试策略制定:确定测试类型、测试环境和测试工具
  • 测试计划编写:制定测试时间表、资源分配和风险评估
  • 测试用例设计:基于需求和设计文档编写详细测试用例
    // 测试用例示例 - 用户登录功能
    describe('用户登录', () => {
    // 测试场景1:有效凭据登录
    it('使用有效凭据应成功登录并重定向到仪表板', async () => {
    // 前置条件、测试步骤、预期结果
    });

    // 测试场景2:无效凭据登录
    it('使用无效凭据应显示错误消息', async () => {
    // 前置条件、测试步骤、预期结果
    });

    // 边界条件和异常场景...
    });

2. 测试环境搭建

  • 环境配置:设置开发、测试和生产环境
  • 测试数据准备:创建测试数据集和测试夹具(fixtures)
  • 工具集成:配置测试框架、断言库和测试运行器
    // Jest配置示例 - jest.config.js
    module.exports = {
    testEnvironment: 'jsdom',
    setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
    moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.\(css|less|scss\)$': 'identity-obj-proxy'
    },
    collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/mocks/**'
    ],
    coverageThreshold: {
    global: {
    statements: 80,
    branches: 80,
    functions: 80,
    lines: 80
    }
    }
    };

3. 测试执行

  • 自动化测试运行:通过命令行或CI/CD管道执行测试
  • 手动测试执行:对于特定场景进行手动测试
  • 测试结果记录:记录测试通过/失败状态和详细日志
    # 运行所有测试
    npm test

    # 运行特定测试文件
    npm test -- path/to/test.spec.js

    # 运行带标签的测试
    npm test -- --tags=smoke,critical

    # 生成覆盖率报告
    npm test -- --coverage

4. 缺陷管理

  • 缺陷报告:记录发现的问题,包括重现步骤、预期结果和实际结果
  • 缺陷分类:按严重性和优先级分类缺陷
  • 缺陷跟踪:使用工具跟踪缺陷状态和解决进度
    ## 缺陷报告模板

    **标题**: 登录按钮在Safari浏览器中不可点击

    **严重性**: 高
    **优先级**: 高
    **环境**: Safari 15.4, macOS 12.3

    **描述**:
    登录页面的提交按钮在Safari浏览器中不可点击,但在Chrome和Firefox中正常工作。

    **重现步骤**:
    1. 打开登录页面
    2. 输入有效的用户名和密码
    3. 点击登录按钮

    **预期结果**:
    用户成功登录并重定向到仪表板

    **实际结果**:
    按钮看起来是禁用状态,点击无反应

    **附件**:
    [截图链接]

5. 测试分析与改进

  • 测试覆盖率分析:评估代码覆盖率并识别测试盲点
  • 测试效率分析:评估测试执行时间和资源消耗
  • 测试策略优化:基于分析结果调整测试策略和优先级
  • 测试自动化改进:增强自动化测试框架和工具
    // 测试覆盖率报告分析示例
    const coverageResults = {
    statements: 87.5,
    branches: 75.0,
    functions: 92.3,
    lines: 88.2
    };

    // 识别低覆盖率区域
    const lowCoverageModules = [
    { name: 'AuthService', branches: 65.2 },
    { name: 'ErrorBoundary', branches: 60.0 }
    ];

    // 测试改进计划
    const improvementPlan = [
    '增加AuthService的分支覆盖率测试',
    '为ErrorBoundary添加更多边界条件测试'
    ];

6. 测试维护

  • 测试代码重构:随着应用代码变化重构测试代码
  • 测试套件优化:移除冗余测试,合并相似测试
  • 测试文档更新:保持测试文档的最新状态
  • 测试知识共享:在团队中分享测试经验和最佳实践

最佳实践

测试方法论

测试驱动开发(TDD)

测试驱动开发是一种开发方法,先编写测试,再实现功能,遵循"红-绿-重构"循环:

  1. :编写一个失败的测试
  2. 绿:编写最简单的代码使测试通过
  3. 重构:改进代码,保持测试通过
// TDD示例 - 实现一个购物车计算功能

// 第1步:编写失败的测试
test('空购物车总价为0', () => {
const cart = new ShoppingCart();
expect(cart.getTotalPrice()).toBe(0);
});

// 第2步:实现最简单的代码使测试通过
class ShoppingCart {
getTotalPrice() {
return 0;
}
}

// 第3步:添加更多测试
test('添加商品后计算总价', () => {
const cart = new ShoppingCart();
cart.addItem({ name: '商品1', price: 10 });
cart.addItem({ name: '商品2', price: 20 });
expect(cart.getTotalPrice()).toBe(30);
});

// 第4步:扩展实现
class ShoppingCart {
constructor() {
this.items = [];
}

addItem(item) {
this.items.push(item);
}

getTotalPrice() {
return this.items.reduce((total, item) => total + item.price, 0);
}
}

// 第5步:继续添加测试和重构...
行为驱动开发(BDD)

行为驱动开发使用自然语言描述测试场景,关注系统行为而非实现细节:

// BDD示例 - 使用Jest和Testing Library

describe('购物车功能', () => {
describe('当用户添加商品到购物车时', () => {
it('应该在购物车中显示商品', async () => {
// 安排(Arrange)
render(<ShoppingCartPage />);

// 执行(Act)
await userEvent.click(screen.getByText('添加到购物车'));

// 断言(Assert)
expect(screen.getByTestId('cart-items')).toHaveTextContent('商品1');
expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
});

it('应该更新购物车总价', async () => {
// 测试实现...
});
});
});
基于属性的测试

基于属性的测试通过生成随机输入数据来测试代码属性和不变量:

// 使用fast-check进行基于属性的测试
import fc from 'fast-check';

test('排序函数应保持数组长度不变', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sortFunction(arr);
return sorted.length === arr.length;
})
);
});

test('排序函数应产生有序数组', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sortFunction(arr);
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i-1]) return false;
}
return true;
})
);
});

测试策略最佳实践

测试金字塔平衡
  • 单元测试:大量快速、低成本的测试
  • 集成测试:中等数量的测试,验证组件协作
  • 端到端测试:少量关键路径测试
// 测试分布示例
const testDistribution = {
unit: { count: 200, runTime: '10秒', cost: '低' },
integration: { count: 50, runTime: '2分钟', cost: '中' },
e2e: { count: 10, runTime: '10分钟', cost: '高' }
};

// 测试策略
const testStrategy = {
unit: '覆盖所有公共方法和关键私有方法',
integration: '覆盖主要组件交互和API调用',
e2e: '覆盖关键用户流程和业务场景'
};
测试隔离与独立性
  • 测试应相互独立,不依赖执行顺序
  • 每次测试后清理环境,避免状态泄漏
  • 使用模拟和存根隔离外部依赖
// 测试隔离示例
describe('用户服务', () => {
// 每个测试前重置状态
beforeEach(() => {
jest.resetAllMocks();
localStorage.clear();
});

// 隔离API调用
jest.mock('../api/userApi');

it('应正确获取用户信息', async () => {
// 模拟API响应
userApi.getUser.mockResolvedValue({ id: 1, name: 'Test User' });

const result = await userService.getUserInfo(1);
expect(result.name).toBe('Test User');
});
});
测试数据管理
  • 使用工厂函数创建测试数据
  • 使用固定的测试数据集(fixtures)
  • 避免在测试中使用随机数据(除非进行基于属性的测试)
// 测试数据工厂
function createUser(overrides = {}) {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
};
}

// 在测试中使用
it('应显示用户信息', () => {
const user = createUser({ role: 'admin' });
render(<UserProfile user={user} />);
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('admin')).toBeInTheDocument();
});

工具推荐

测试框架与运行器

  • Jest: 全功能JavaScript测试框架,内置断言、模拟和覆盖率报告功能,适用于React项目
  • Vitest: 基于Vite的快速单元测试框架,兼容Jest API,对Vite/Vue项目有优化
  • Mocha: 灵活的JavaScript测试框架,可与多种断言库配合使用,高度可定制
  • Jasmine: 行为驱动开发框架,内置断言和模拟功能,语法直观
  • Karma: 测试运行器,可在多种真实浏览器中执行测试,适合跨浏览器测试
  • AVA: 并行测试运行器,简洁的API和良好的TypeScript支持,适合大型项目

组件测试工具

  • React Testing Library: 鼓励测试用户行为而非实现细节的React测试工具,React官方推荐
  • Vue Test Utils: Vue官方的组件测试工具,提供丰富的组件交互API
  • Enzyme: React组件测试工具,允许访问组件内部状态和方法,适合单元测试
  • Testing Library系列: 包括React、Vue、Angular等框架的统一测试方法论实现
  • Svelte Testing Library: Svelte组件测试工具,遵循Testing Library的理念
  • Angular Testing Utilities: Angular官方测试工具,与Angular框架深度集成

端到端测试工具

  • Cypress: 现代化端到端测试框架,提供实时重载、时间旅行调试和自动等待功能
  • Playwright: 微软开发的端到端测试框架,支持所有现代浏览器,自动等待和强大的选择器
  • Puppeteer: Google开发的无头Chrome控制API,适用于自动化和性能测试
  • Selenium WebDriver: 跨浏览器自动化测试工具,支持最广泛的浏览器,成熟稳定
  • TestCafe: 无需WebDriver的跨浏览器测试框架,安装简单,内置等待机制

模拟与存根工具

  • Mock Service Worker (MSW): 通过拦截网络请求进行API模拟,可用于开发和测试
  • Sinon.js: JavaScript测试模拟库,提供间谍、存根和模拟功能,灵活强大
  • Nock: HTTP服务器模拟和期望库,适合API测试
  • Mirage JS: 客户端API模拟库,可创建、测试和共享完整的模拟API
  • Jest Mock Functions: Jest内置的模拟功能,简单易用

可视化测试工具

  • Storybook: UI组件开发和测试环境,支持多种前端框架
  • Chromatic: 基于Storybook的可视化测试和审查平台,自动捕捉UI变化
  • Percy: 可视化测试和审查平台,支持多种框架和浏览器
  • Applitools Eyes: AI驱动的可视化测试工具,智能识别有意义的UI变化
  • BackstopJS: 开源视觉回归测试工具,配置简单

性能与负载测试工具

  • Lighthouse: Google开发的网页性能、可访问性和最佳实践审计工具
  • WebPageTest: 详细的网页性能测试工具,提供多地区、多设备测试
  • k6: 开源负载测试工具,使用JavaScript编写测试脚本
  • Siege: HTTP负载测试和基准测试工具,适合API性能测试
  • React Profiler: React内置的性能分析工具,识别组件渲染瓶颈

可访问性测试工具

  • axe-core: 自动化Web可访问性测试引擎,可集成到测试流程
  • Pa11y: 命令行可访问性测试工具,支持CI集成
  • WAVE: Web可访问性评估工具,提供可视化报告
  • Lighthouse: 包含可访问性审计的性能测试工具
  • eslint-plugin-jsx-a11y: React项目的可访问性静态分析工具

安全测试工具

  • OWASP ZAP: 开源Web应用安全扫描器,发现常见安全漏洞
  • Snyk: 依赖安全扫描工具,检测和修复依赖中的漏洞
  • npm audit: npm内置的依赖安全审计工具,简单易用
  • ESLint安全插件: 静态代码分析以检测安全问题,如eslint-plugin-security
  • SonarQube: 代码质量和安全性分析平台,支持多种语言

CI/CD集成工具

  • GitHub Actions: GitHub集成的CI/CD平台,配置简单,与GitHub深度集成
  • CircleCI: 云原生CI/CD平台,支持复杂工作流和并行测试
  • Jenkins: 开源自动化服务器,高度可定制,插件丰富
  • Travis CI: 分布式CI服务,配置简单,适合开源项目
  • GitLab CI/CD: GitLab集成的CI/CD平台,与GitLab代码库无缝协作