前端测试的重要性与基础概念

在现代前端开发中,测试已经成为保证代码质量和开发效率的关键环节。随着前端应用复杂度的不断提升,仅仅依靠手动测试已经无法满足快速迭代的需求。前端测试不仅能够帮助我们发现潜在的bug,还能为代码重构提供安全保障,让开发团队更加自信地进行代码改进。

前端测试主要分为三个层次:单元测试、集成测试和端到端(E2E)测试。这三种测试类型各有侧重,相互补充,构成了完整的测试金字塔。单元测试关注单个函数或组件的行为,集成测试验证多个模块之间的协作,而端到端测试则模拟真实用户的操作流程。

为什么前端测试如此重要?

  1. 提高代码质量:通过测试可以及早发现bug,避免问题流入生产环境
  2. 支持重构:完善的测试套件让代码重构变得更加安全
  3. 文档作用:测试用例本身就是最好的使用文档
  4. 提升开发效率:虽然初期会增加开发时间,但长期来看能大幅减少调试时间
  5. 团队协作:清晰的测试用例帮助团队成员理解代码预期行为

单元测试:从零开始

单元测试基础

单元测试是前端测试金字塔的基石,它专注于测试最小的可测试单元——通常是函数、方法或组件。在JavaScript生态中,Jest是最受欢迎的单元测试框架之一。

Jest基础配置

首先,让我们创建一个简单的项目并配置Jest:

# 初始化项目
mkdir frontend-testing-course
cd frontend-testing-course
npm init -y

# 安装Jest
npm install --save-dev jest

# 修改package.json添加测试脚本
{
  "name": "frontend-testing-course",
  "version": "1.0.0",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  },
  "devDependencies": {
    "jest": "^29.0.0"
  }
}

编写第一个单元测试

让我们从一个简单的数学函数开始:

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

function multiply(a, b) {
    return a * b;
}

function divide(a, b) {
    if (b === 0) {
        throw new Error("Cannot divide by zero");
    }
    return a / b;
}

module.exports = { add, multiply, divide };

对应的测试文件:

// math.test.js
const { add, multiply, divide } = require('./math');

describe('数学运算函数测试', () => {
    describe('add函数', () => {
        test('应该正确计算两个正数的和', () => {
            expect(add(2, 3)).toBe(5);
        });

        test('应该正确处理负数', () => {
            expect(add(-1, -2)).toBe(-3);
        });

        test('应该正确处理零', () => {
            expect(add(0, 5)).toBe(5);
        });
    });

    describe('multiply函数', () => {
        test('应该正确计算乘积', () => {
            expect(multiply(3, 4)).toBe(12);
        });

        test('应该正确处理零', () => {
            expect(multiply(0, 100)).toBe(0);
        });
    });

    describe('divide函数', () => {
        test('应该正确计算除法', () => {
            expect(divide(10, 2)).toBe(5);
        });

        test('应该抛出错误当除数为零', () => {
            expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
        });
    });
});

React组件单元测试

对于React组件,我们需要额外的工具来渲染组件并进行断言。这里使用React Testing Library:

npm install --save-dev @testing-library/react @testing-library/jest-dom jest-environment-jsdom

创建一个简单的React组件:

// Button.jsx
import React from 'react';

const Button = ({ onClick, children, disabled = false, variant = 'primary' }) => {
    const baseClasses = 'px-4 py-2 rounded font-medium transition-colors';
    const variantClasses = {
        primary: 'bg-blue-500 text-white hover:bg-blue-600',
        secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
        danger: 'bg-red-500 text-white hover:bg-red-600'
    };

    return (
        <button
            onClick={onClick}
            disabled={disabled}
            className={`${baseClasses} ${variantClasses[variant]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
        >
            {children}
        </button>
    );
};

export default Button;

对应的测试文件:

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

describe('Button组件测试', () => {
    test('应该正确渲染按钮文本', () => {
        render(<Button>点击我</Button>);
        expect(screen.getByText('点击我')).toBeInTheDocument();
    });

    test('应该触发onClick事件', () => {
        const handleClick = jest.fn();
        render(<Button onClick={handleClick}>测试按钮</Button>);
        
        fireEvent.click(screen.getByText('测试按钮'));
        expect(handleClick).toHaveBeenCalledTimes(1);
    });

    test('应该禁用按钮当disabled为true', () => {
        render(<Button disabled={true}>禁用按钮</Button>);
        const button = screen.getByText('禁用按钮');
        expect(button).toBeDisabled();
    });

    test('应该应用正确的样式变体', () => {
        const { rerender } = render(<Button variant="primary">主要按钮</Button>);
        const button = screen.getByText('主要按钮');
        expect(button).toHaveClass('bg-blue-500');

        rerender(<Button variant="danger">危险按钮</Button>);
        expect(screen.getByText('危险按钮')).toHaveClass('bg-red-500');
    });
});

高级单元测试技巧

1. Mock函数和模拟

// api.js
async function fetchUserData(userId) {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
        throw new Error('Failed to fetch user');
    }
    return response.json();
}

// api.test.js
describe('fetchUserData', () => {
    beforeEach(() => {
        global.fetch = jest.fn();
    });

    test('应该成功获取用户数据', async () => {
        const mockUser = { id: 1, name: 'John Doe' };
        global.fetch.mockResolvedValue({
            ok: true,
            json: async () => mockUser
        });

        const result = await fetchUserData(1);
        expect(result).toEqual(mockUser);
        expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
    });

    test('应该处理API错误', async () => {
        global.fetch.mockResolvedValue({
            ok: false,
            status: 404
        });

        await expect(fetchUserData(1)).rejects.toThrow('Failed to fetch user');
    });
});

2. 异步测试

// asyncUtils.js
function delayResolve(value, delay) {
    return new Promise(resolve => setTimeout(() => resolve(value), delay));
}

function delayReject(error, delay) {
    return new Promise((_, reject) => setTimeout(() => reject(error), delay));
}

// asyncUtils.test.js
describe('异步工具函数', () => {
    test('delayResolve应该在延迟后解析', async () => {
        const start = Date.now();
        const result = await delayResolve('success', 100);
        const end = Date.now();
        
        expect(result).toBe('success');
        expect(end - start).toBeGreaterThanOrEqual(100);
    });

    test('delayReject应该在延迟后拒绝', async () => {
        await expect(delayReject(new Error('failed'), 100)).rejects.toThrow('failed');
    });
});

集成测试:验证模块协作

集成测试基础

集成测试关注多个模块如何协同工作。在前端中,这通常涉及组件之间的交互、API调用、状态管理等。

React组件集成测试示例

让我们创建一个包含表单和API调用的复杂场景:

// UserForm.jsx
import React, { useState } from 'react';

const UserForm = ({ onSubmit }) => {
    const [formData, setFormData] = useState({ name: '', email: '' });
    const [isSubmitting, setIsSubmitting] = useState(false);
    const [error, setError] = useState(null);

    const handleSubmit = async (e) => {
        e.preventDefault();
        setIsSubmitting(true);
        setError(null);

        try {
            await onSubmit(formData);
            setFormData({ name: '', email: '' });
        } catch (err) {
            setError(err.message);
        } finally {
            setIsSubmitting(false);
        }
    };

    const handleChange = (e) => {
        const { name, value } = e.target;
        setFormData(prev => ({ ...prev, [name]: value }));
    };

    return (
        <form onSubmit={handleSubmit} data-testid="user-form">
            <div>
                <label htmlFor="name">姓名:</label>
                <input
                    id="name"
                    name="name"
                    value={formData.name}
                    onChange={handleChange}
                    disabled={isSubmitting}
                    data-testid="name-input"
                />
            </div>
            <div>
                <label htmlFor="email">邮箱:</label>
                <input
                    id="email"
                    name="email"
                    type="email"
                    value={formData.email}
                    onChange={handleChange}
                    disabled={isSubmitting}
                    data-testid="email-input"
                />
            </div>
            {error && <div data-testid="error-message">{error}</div>}
            <button 
                type="submit" 
                disabled={isSubmitting}
                data-testid="submit-button"
            >
                {isSubmitting ? '提交中...' : '提交'}
            </button>
        </form>
    );
};

export default UserForm;

集成测试:

// UserForm.test.jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserForm from './UserForm';

describe('UserForm集成测试', () => {
    test('应该完整处理表单提交流程', async () => {
        const mockSubmit = jest.fn().mockResolvedValue({ success: true });
        
        render(<UserForm onSubmit={mockSubmit} />);

        // 填写表单
        const nameInput = screen.getByTestId('name-input');
        const emailInput = screen.getByTestId('email-input');
        const submitButton = screen.getByTestId('submit-button');

        fireEvent.change(nameInput, { target: { name: 'name', value: '张三' } });
        fireEvent.change(emailInput, { target: { name: 'email', value: 'zhangsan@example.com' } });

        // 验证输入值
        expect(nameInput.value).toBe('张三');
        expect(emailInput.value).toBe('zhangsan@example.com');

        // 提交表单
        fireEvent.click(submitButton);

        // 验证提交状态
        expect(submitButton).toBeDisabled();
        expect(submitButton.textContent).toBe('提交中...');

        // 等待提交完成
        await waitFor(() => {
            expect(mockSubmit).toHaveBeenCalledWith({
                name: '张三',
                email: 'zhangsan@example.com'
            });
        });

        // 验证表单重置
        expect(nameInput.value).toBe('');
        expect(emailInput.value).toBe('');
        expect(submitButton).not.toBeDisabled();
    });

    test('应该处理提交错误', async () => {
        const mockSubmit = jest.fn().mockRejectedValue(new Error('服务器错误'));
        
        render(<UserForm onSubmit={mockSubmit} />);

        const nameInput = screen.getByTestId('name-input');
        const emailInput = screen.getByTestId('email-input');
        const submitButton = screen.getByTestId('submit-button');

        fireEvent.change(nameInput, { target: { name: 'name', value: '李四' } });
        fireEvent.change(emailInput, { target: { name: 'email', value: 'lisi@example.com' } });
        fireEvent.click(submitButton);

        await waitFor(() => {
            const errorElement = screen.getByTestId('error-message');
            expect(errorElement).toHaveTextContent('服务器错误');
        });

        // 验证表单仍然保持错误状态
        expect(nameInput.value).toBe('李四');
        expect(submitButton).not.toBeDisabled();
    });
});

状态管理集成测试

对于使用Redux或Context API的应用,集成测试尤为重要:

// store.js
import { createStore } from 'redux';

function todoReducer(state = [], action) {
    switch (action.type) {
        case 'ADD_TODO':
            return [...state, { id: Date.now(), text: action.payload, completed: false }];
        case 'TOGGLE_TODO':
            return state.map(todo =>
                todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
            );
        case 'DELETE_TODO':
            return state.filter(todo => todo.id !== action.payload);
        default:
            return state;
    }
}

const store = createStore(todoReducer);
export default store;

// TodoApp.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';

const TodoApp = () => {
    const todos = useSelector(state => state);
    const dispatch = useDispatch();
    const [inputValue, setInputValue] = React.useState('');

    const handleAdd = () => {
        if (inputValue.trim()) {
            dispatch({ type: 'ADD_TODO', payload: inputValue });
            setInputValue('');
        }
    };

    return (
        <div>
            <input
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
                data-testid="todo-input"
            />
            <button onClick={handleAdd} data-testid="add-button">添加</button>
            <ul data-testid="todo-list">
                {todos.map(todo => (
                    <li key={todo.id} data-testid={`todo-${todo.id}`}>
                        <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
                            {todo.text}
                        </span>
                        <button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}>
                            {todo.completed ? '取消' : '完成'}
                        </button>
                        <button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
                            删除
                        </button>
                    </li>
                ))}
            </ul>
        </div>
    );
};

export default TodoApp;
// TodoApp.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import '@testing-library/jest-dom';
import TodoApp, { todoReducer } from './TodoApp';

// 创建测试专用的store
const renderWithRedux = (
    component,
    { initialState, store = createStore(todoReducer, initialState) } = {}
) => {
    return {
        ...render(<Provider store={store}>{component}</Provider>),
        store
    };
};

describe('TodoApp集成测试', () => {
    test('应该添加新todo', () => {
        renderWithRedux(<TodoApp />);

        const input = screen.getByTestId('todo-input');
        const button = screen.getByTestId('add-button');

        fireEvent.change(input, { target: { value: '学习测试' } });
        fireEvent.click(button);

        expect(screen.getByTestId('todo-list')).toHaveTextContent('学习测试');
        expect(input.value).toBe('');
    });

    test('应该切换todo完成状态', () => {
        const initialState = [{ id: 1, text: '测试任务', completed: false }];
        renderWithRedux(<TodoApp />, { initialState });

        const toggleButton = screen.getByText('完成');
        fireEvent.click(toggleButton);

        const todoItem = screen.getByTestId('todo-1');
        expect(todoItem.querySelector('span')).toHaveStyle('text-decoration: line-through');
        expect(screen.getByText('取消')).toBeInTheDocument();
    });

    test('应该删除todo', () => {
        const initialState = [
            { id: 1, text: '任务1', completed: false },
            { id: 2, text: '任务2', completed: false }
        ];
        renderWithRedux(<TodoApp />, { initialState });

        expect(screen.getByTestId('todo-1')).toBeInTheDocument();
        expect(screen.getByTestId('todo-2')).toBeInTheDocument();

        const deleteButtons = screen.getAllByText('删除');
        fireEvent.click(deleteButtons[0]);

        expect(screen.queryByTestId('todo-1')).not.toBeInTheDocument();
        expect(screen.getByTestId('todo-2')).toBeInTheDocument();
    });
});

端到端(E2E)测试:模拟真实用户场景

E2E测试基础

端到端测试模拟真实用户的行为,从浏览器层面验证整个应用的流程。Cypress是目前最流行的前端E2E测试框架。

Cypress安装和配置

npm install --save-dev cypress

在package.json中添加脚本:

{
  "scripts": {
    "cypress:open": "cypress open",
    "cypress:run": "cypress run"
  }
}

创建Cypress配置文件:

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

module.exports = defineConfig({
    e2e: {
        baseUrl: 'http://localhost:3000',
        setupNodeEvents(on, config) {
            // 实现插件事件
        },
        specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
        supportFile: 'cypress/support/e2e.js'
    },
    component: {
        devServer: {
            framework: 'react',
            bundler: 'webpack'
        }
    }
});

编写E2E测试用例

让我们为一个电商网站的购物流程编写E2E测试:

// cypress/e2e/shopping-flow.cy.js

describe('电商购物流程测试', () => {
    beforeEach(() => {
        cy.visit('/');
    });

    it('应该完成完整的购买流程', () => {
        // 1. 登录
        cy.get('[data-testid="login-button"]').click();
        cy.get('[data-testid="username-input"]').type('testuser');
        cy.get('[data-testid="password-input"]').type('password123');
        cy.get('[data-testid="submit-login"]').click();
        
        // 验证登录成功
        cy.url().should('include', '/dashboard');
        cy.get('[data-testid="user-welcome"]').should('contain', '欢迎');

        // 2. 搜索商品
        cy.get('[data-testid="search-input"]').type('iPhone 15');
        cy.get('[data-testid="search-button"]').click();

        // 3. 选择商品
        cy.get('[data-testid="product-item"]').first().click();

        // 4. 添加到购物车
        cy.get('[data-testid="add-to-cart"]').click();
        
        // 验证购物车数量
        cy.get('[data-testid="cart-count"]').should('contain', '1');

        // 5. 进入购物车
        cy.get('[data-testid="cart-icon"]').click();
        cy.url().should('include', '/cart');

        // 6. 结账
        cy.get('[data-testid="checkout-button"]').click();

        // 7. 填写配送信息
        cy.get('[data-testid="shipping-name"]').type('张三');
        cy.get('[data-testid="shipping-address"]').type('北京市朝阳区');
        cy.get('[data-testid="shipping-phone"]').type('13800138000');

        // 8. 选择支付方式
        cy.get('[data-testid="payment-method"]').select('信用卡');
        cy.get('[data-testid="card-number"]').type('4111111111111111');
        cy.get('[data-testid="card-expiry"]').type('12/25');
        cy.get('[data-testid="card-cvv"]').type('123');

        // 9. 提交订单
        cy.get('[data-testid="place-order"]').click();

        // 10. 验证订单成功
        cy.url().should('include', '/order-confirmation');
        cy.get('[data-testid="order-success"]').should('be.visible');
        cy.get('[data-testid="order-number"]').should('exist');
    });

    it('应该处理库存不足的情况', () => {
        // 模拟库存不足的商品
        cy.intercept('GET', '/api/products/low-stock', {
            statusCode: 200,
            body: {
                id: 999,
                name: '限量商品',
                price: 999,
                stock: 1
            }
        });

        cy.visit('/product/low-stock');
        
        // 快速添加多个商品到购物车
        cy.get('[data-testid="add-to-cart"]').click().click().click();

        // 验证错误提示
        cy.get('[data-testid="error-message"]').should('contain', '库存不足');
    });

    it('应该验证表单验证', () => {
        cy.visit('/register');

        // 尝试提交空表单
        cy.get('[data-testid="submit-button"]').click();

        // 验证错误提示
        cy.get('[data-testid="email-error"]').should('contain', '邮箱不能为空');
        cy.get('[data-testid="password-error"]').should('contain', '密码不能为空');

        // 填写无效数据
        cy.get('[data-testid="email-input"]').type('invalid-email');
        cy.get('[data-testid="password-input"]').type('123');
        cy.get('[data-testid="submit-button"]').click();

        cy.get('[data-testid="email-error"]').should('contain', '邮箱格式不正确');
        cy.get('[data-testid="password-error"]').should('contain', '密码至少需要6个字符');
    });
});

高级E2E测试技巧

1. 网络请求拦截

// cypress/e2e/api-testing.cy.js

describe('API集成测试', () => {
    it('应该正确处理API响应', () => {
        // 拦截API请求并返回模拟数据
        cy.intercept('GET', '/api/users', {
            statusCode: 200,
            body: [
                { id: 1, name: 'Alice', email: 'alice@example.com' },
                { id: 2, name: 'Bob', email: 'bob@example.com' }
            ]
        }).as('getUsers');

        cy.visit('/users');

        cy.wait('@getUsers').then((interception) => {
            expect(interception.response.statusCode).to.equal(200);
            expect(interception.response.body).to.have.length(2);
        });

        cy.get('[data-testid="user-list"] li').should('have.length', 2);
    });

    it('应该处理API错误', () => {
        cy.intercept('POST', '/api/login', {
            statusCode: 401,
            body: { error: 'Invalid credentials' }
        });

        cy.visit('/login');
        cy.get('[data-testid="username"]').type('wronguser');
        cy.get('[data-testid="password"]').type('wrongpass');
        cy.get('[data-testid="submit"]').click();

        cy.get('[data-testid="error-message"]').should('contain', 'Invalid credentials');
    });
});

2. 性能测试

// cypress/e2e/performance.cy.js

describe('性能测试', () => {
    it('应该在2秒内加载首页', () => {
        cy.visit('/', {
            onBeforeLoad: (win) => {
                win.performance.mark('page-load-start');
            }
        });

        cy.get('[data-testid="main-content"]').should('be.visible').then(() => {
            cy.window().then((win) => {
                win.performance.mark('page-load-end');
                win.performance.measure('page-load', 'page-load-start', 'page-load-end');
                const measure = win.performance.getEntriesByName('page-load')[0];
                expect(measure.duration).to.be.lessThan(2000);
            });
        });
    });

    it('应该验证关键资源加载', () => {
        const resources = [];
        cy.intercept('*', (req) => {
            req.continue((res) => {
                resources.push({
                    url: req.url,
                    size: res.headers['content-length'],
                    duration: res.duration
                });
            });
        });

        cy.visit('/dashboard');

        cy.then(() => {
            const mainBundle = resources.find(r => r.url.includes('main.js'));
            expect(mainBundle).to.exist;
            expect(mainBundle.duration).to.be.lessThan(1000);
        });
    });
});

测试最佳实践和策略

测试金字塔原则

        / \
       / E2E \      <- 少量端到端测试
      /_______\
     /   API   \    <- 中等数量集成测试
    /___________\
   /  Unit Tests \  <- 大量单元测试
  /_______________\

测试命名规范

好的测试名称应该清晰描述测试意图:

// 不好的命名
test('test1', () => { /* ... */ });
test('add function', () => { /* ... */ });

// 好的命名
test('当输入为负数时,add函数应该返回正确的和', () => { /* ... */ });
test('应该在用户点击提交按钮时显示加载状态', () => { /* ... */ });

测试数据管理

// 测试数据工厂
const createTestUser = (overrides = {}) => ({
    id: 1,
    name: 'Test User',
    email: 'test@example.com',
    role: 'user',
    ...overrides
});

// 在测试中使用
test('应该显示管理员标签', () => {
    const adminUser = createTestUser({ role: 'admin' });
    render(<UserProfile user={adminUser} />);
    expect(screen.getByText('管理员')).toBeInTheDocument();
});

测试隔离原则

// 每个测试应该独立运行
describe('用户管理', () => {
    // 使用beforeEach确保每个测试都有干净的环境
    beforeEach(() => {
        // 重置所有状态
        cleanup();
        // 重置mock
        jest.clearAllMocks();
    });

    test('测试1', () => { /* ... */ });
    test('测试2', () => { /* ... */ });
});

持续集成与自动化

GitHub Actions配置

# .github/workflows/test.yml
name: Frontend Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run unit tests
      run: npm test -- --coverage
    
    - name: Run E2E tests
      uses: cypress-io/github-action@v5
      with:
        start: npm start
        wait-on: 'http://localhost:3000'
        record: true
      env:
        CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
    
    - name: Upload coverage reports
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage/lcov.info

测试覆盖率报告

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

实战项目:构建完整的测试套件

让我们通过一个完整的项目来实践所有学到的知识。

项目结构

frontend-testing-project/
├── src/
│   ├── components/
│   │   ├── Button.jsx
│   │   ├── Form.jsx
│   │   └── ProductList.jsx
│   ├── hooks/
│   │   ├── useAuth.js
│   │   └── useCart.js
│   ├── services/
│   │   ├── api.js
│   │   └── storage.js
│   └── App.jsx
├── tests/
│   ├── unit/
│   │   ├── components/
│   │   ├── hooks/
│   │   └── services/
│   ├── integration/
│   │   └── flows/
│   └── e2e/
│       └── cypress/
├── cypress.config.js
└── jest.config.js

完整的测试示例

// src/services/api.js
class API {
    constructor(baseURL) {
        this.baseURL = baseURL;
    }

    async request(endpoint, options = {}) {
        const url = `${this.baseURL}${endpoint}`;
        const response = await fetch(url, {
            headers: {
                'Content-Type': 'application/json',
                ...options.headers
            },
            ...options
        });

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.message || 'API Error');
        }

        return response.json();
    }

    async getProducts() {
        return this.request('/products');
    }

    async createOrder(orderData) {
        return this.request('/orders', {
            method: 'POST',
            body: JSON.stringify(orderData)
        });
    }
}

// tests/unit/services/api.test.js
const { API } = require('../../src/services/api');

describe('API Service', () => {
    let api;
    let mockFetch;

    beforeEach(() => {
        api = new API('https://api.example.com');
        mockFetch = jest.fn();
        global.fetch = mockFetch;
    });

    afterEach(() => {
        jest.restoreAllMocks();
    });

    describe('request method', () => {
        it('应该成功处理GET请求', async () => {
            const mockData = { success: true };
            mockFetch.mockResolvedValue({
                ok: true,
                json: async () => mockData
            });

            const result = await api.request('/test');
            expect(result).toEqual(mockData);
            expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
                headers: { 'Content-Type': 'application/json' }
            });
        });

        it('应该处理API错误', async () => {
            mockFetch.mockResolvedValue({
                ok: false,
                json: async () => ({ message: 'Not found' })
            });

            await expect(api.request('/not-found')).rejects.toThrow('Not found');
        });
    });

    describe('getProducts', () => {
        it('应该获取产品列表', async () => {
            const mockProducts = [{ id: 1, name: 'Product 1' }];
            mockFetch.mockResolvedValue({
                ok: true,
                json: async () => mockProducts
            });

            const result = await api.getProducts();
            expect(result).toEqual(mockProducts);
            expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/products', {
                headers: { 'Content-Type': 'application/json' }
            });
        });
    });
});

集成测试示例

// src/hooks/useCart.js
import { useState, useEffect } from 'react';
import { API } from '../services/api';

export const useCart = () => {
    const [cart, setCart] = useState([]);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);
    const api = new API('https://api.example.com');

    const addToCart = async (product) => {
        setLoading(true);
        setError(null);
        try {
            const updatedCart = await api.createOrder({ ...product, quantity: 1 });
            setCart(updatedCart);
        } catch (err) {
            setError(err.message);
        } finally {
            setLoading(false);
        }
    };

    const removeFromCart = (productId) => {
        setCart(prev => prev.filter(item => item.id !== productId));
    };

    const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

    return { cart, loading, error, addToCart, removeFromCart, total };
};

// tests/integration/hooks/useCart.test.js
import { renderHook, act } from '@testing-library/react';
import { useCart } from '../../../src/hooks/useCart';
import { API } from '../../../src/services/api';

jest.mock('../../../src/services/api');

describe('useCart Hook Integration', () => {
    let mockApi;

    beforeEach(() => {
        mockApi = {
            createOrder: jest.fn()
        };
        API.mockImplementation(() => mockApi);
    });

    test('应该成功添加商品到购物车', async () => {
        const mockProduct = { id: 1, name: 'Product', price: 100 };
        const mockCart = [{ id: 1, name: 'Product', price: 100, quantity: 1 }];
        mockApi.createOrder.mockResolvedValue(mockCart);

        const { result } = renderHook(() => useCart());

        await act(async () => {
            await result.current.addToCart(mockProduct);
        });

        expect(result.current.cart).toEqual(mockCart);
        expect(result.current.total).toBe(100);
        expect(result.current.loading).toBe(false);
        expect(result.current.error).toBeNull();
    });

    test('应该处理添加商品失败', async () => {
        mockApi.createOrder.mockRejectedValue(new Error('库存不足'));

        const { result } = renderHook(() => useCart());

        await act(async () => {
            await result.current.addToCart({ id: 1 });
        });

        expect(result.current.error).toBe('库存不足');
        expect(result.current.loading).toBe(false);
        expect(result.current.cart).toEqual([]);
    });
});

测试工具和生态系统

测试覆盖率工具

// 生成详细的覆盖率报告
// package.json scripts
{
  "scripts": {
    "test:coverage": "jest --coverage",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --coverage --maxWorkers=2",
    "cypress:run": "cypress run",
    "cypress:open": "cypress open",
    "test:e2e": "start-server-and-test start http://localhost:3000 cypress:run"
  }
}

测试监控和报告

// jest.config.js with advanced settings
module.exports = {
    verbose: true,
    testEnvironment: 'jsdom',
    setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
    collectCoverageFrom: [
        'src/**/*.{js,jsx,ts,tsx}',
        '!src/**/*.test.{js,jsx,ts,tsx}',
        '!src/index.js',
        '!src/reportWebVitals.js'
    ],
    coverageThreshold: {
        global: {
            branches: 80,
            functions: 80,
            lines: 80,
            statements: 80
        },
        './src/components/**/*.js': {
            branches: 90,
            functions: 90
        }
    },
    coverageReporters: [
        'text',
        'lcov',
        'html',
        'json-summary'
    ],
    testMatch: [
        '**/tests/**/*.test.js',
        '**/?(*.)+(spec|test).js'
    ],
    transform: {
        '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
    },
    moduleNameMapper: {
        '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
        '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js'
    }
};

总结与进阶建议

通过本课程的学习,我们从零基础开始,逐步掌握了前端测试的三个核心层次:

  1. 单元测试:关注最小可测试单元,使用Jest进行函数和组件的测试
  2. 集成测试:验证模块间的协作,测试组件组合和状态管理
  3. 端到端测试:模拟真实用户场景,使用Cypress进行完整流程验证

持续改进的建议

  1. 逐步实施:从新功能开始写测试,逐步覆盖旧代码
  2. 测试驱动开发(TDD):先写测试,再写实现代码
  3. 代码审查:将测试用例作为代码审查的一部分
  4. 监控指标:关注测试覆盖率、测试执行时间、失败率等指标
  5. 定期重构:测试代码也需要维护和重构

学习资源推荐

记住,好的测试不是为了100%的覆盖率,而是为了提供信心和安全保障。测试应该是开发的助力,而不是阻力。通过合理的测试策略和工具选择,我们可以显著提升代码质量和开发效率。