实施全面的前端测试,使用Jest、Vitest、React Testing Library和Cypress。在构建UI和集成测试的强大测试套件时使用。
概述
构建全面的前端应用程序测试套件,包括单元测试、集成测试和端到端测试,具有适当的覆盖范围和断言。
使用场景
- 组件测试
- 集成测试
- 端到端测试
- 回归预防
- 质量保证
- 测试驱动开发
实施示例
1. Jest单元测试(React)
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Button } from './Button';
describe('Button 组件', () => {
it('渲染带有文本的按钮', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
it('点击时调用onClick处理器', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('当disabled属性为true时禁用按钮', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('正确应用变体样式', () => {
const { container } = render(<Button variant="primary">Click</Button>);
const button = container.querySelector('button');
expect(button).toHaveClass('bg-blue-500');
});
it('正确应用大小类', () => {
const { container } = render(<Button size="lg">Click</Button>);
const button = container.querySelector('button');
expect(button).toHaveClass('px-6 py-3 text-lg');
});
});
// hooks.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('以默认值初始化', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('增加计数', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('减少计数', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('重置计数', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
2. React Testing Library集成测试
// UserForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserForm } from './UserForm';
describe('UserForm集成', () => {
beforeEach(() => {
// 每个测试前清除mocks
jest.clearAllMocks();
});
it('提交带有有效数据的表单', async () => {
const handleSubmit = jest.fn();
render(<UserForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/name/i), 'John Doe');
await userEvent.type(screen.getByLabelText(/email/i), 'john@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
});
});
});
it('显示空字段的验证错误', async () => {
render(<UserForm onSubmit={jest.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
it('显示无效电子邮件的验证错误', async () => {
render(<UserForm onSubmit={jest.fn()} />);
await userEvent.type(screen.getByLabelText(/email/i), 'invalid-email');
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
});
// UserList.test.tsx与数据获取
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
describe('UserList与API', () => {
beforeEach(() => {
jest.spyOn(global, 'fetch').mockClear();
});
it('最初显示加载状态', () => {
(global.fetch as jest.Mock).mockImplementation(
() => new Promise(() => {}) // 永不解决
);
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('获取并显示用户', async () => {
const mockUsers = [
{ id: 1, name: 'User 1', email: 'user1@example.com' },
{ id: 2, name: 'User 2', email: 'user2@example.com' }
];
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUsers
});
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('User 1')).toBeInTheDocument();
expect(screen.getByText('User 2')).toBeInTheDocument();
});
});
it('在获取失败时显示错误消息', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
3. Vitest用于Vue测试
// Button.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('Button.vue', () => {
it('渲染插槽内容', () => {
const wrapper = mount(Button, {
slots: {
default: 'Click me'
}
});
expect(wrapper.text()).toContain('Click me');
});
it('发出点击事件', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('当disabled属性为true时禁用按钮', () => {
const wrapper = mount(Button, {
props: { disabled: true }
});
expect(wrapper.attributes('disabled')).toBeDefined();
});
it('应用变体类', () => {
const wrapper = mount(Button, {
props: { variant: 'primary' }
});
expect(wrapper.classes()).toContain('bg-blue-500');
});
});
// composable.spec.ts
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('以默认值初始化', () => {
const { count } = useCounter();
expect(count.value).toBe(0);
});
it('增加计数', () => {
const { count, increment } = useCounter();
increment();
expect(count.value).toBe(1);
});
});
4. Cypress端到端测试
// cypress/e2e/login.cy.ts
describe('登录流程', () => {
beforeEach(() => {
cy.visit('http://localhost:3000/login');
});
it('使用有效凭证登录', () => {
cy.get('input[name="email"]').type('user@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.get('h1').should('contain', 'Welcome');
});
it('显示无效凭证的错误', () => {
cy.get('input[name="email"]').type('user@example.com');
cy.get('input[name="password"]').type('wrongpassword');
cy.get('button[type="submit"]').click();
cy.get('.error-message').should('contain', 'Invalid credentials');
});
it('验证电子邮件字段', () => {
cy.get('input[name="email"]').type('invalid-email');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
cy.get('.error-message').should('contain', 'Invalid email');
});
});
// cypress/e2e/user-management.cy.ts
describe('用户管理', () => {
beforeEach(() => {
cy.login('admin@example.com', 'password123');
cy.visit('http://localhost:3000/users');
});
it('创建新用户', () => {
cy.get('button:contains("Add User")').click();
cy.get('input[name="name"]').type('New User');
cy.get('input[name="email"]').type('newuser@example.com');
cy.get('button[type="submit"]').click();
cy.get('.success-message').should('contain', 'User created');
cy.get('table tbody').should('contain', 'New User');
});
it('编辑现有用户', () => {
cy.get('table tbody tr').first().contains('button', 'Edit').click();
cy.get('input[name="name"]').clear().type('Updated Name');
cy.get('button[type="submit"]').click();
cy.get('.success-message').should('contain', 'User updated');
});
it('确认后删除用户', () => {
cy.get('table tbody tr').first().contains('button', 'Delete').click();
cy.get('.modal button:contains("Confirm")').click();
cy.get('.success-message').should('contain', 'User deleted');
});
});
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('http://localhost:3000/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
});
5. 测试覆盖率配置
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts?(x)', '**/?(*.)+(spec|test).ts?(x)'],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/reportWebVitals.ts'
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: {
jsx: 'react-jsx'
}
}]
}
};
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"cypress": "cypress open",
"cypress:headless": "cypress run"
}
}
最佳实践
- 代码旁写测试(TDD)
- 测试行为,而不是实现
- 使用描述性测试名称
- 保持测试专注和独立
- 模拟外部依赖
- 目标高覆盖率(>80%)
- 在React Testing Library中使用语义查询
- 对关键路径实施端到端测试
- 测试错误场景
- 使用CI/CD进行自动化测试