作为 Vue 开发者,在迁移到 React 开发时,测试策略和方法也需要相应调整。本文将从 Vue 开发者熟悉的角度出发,详细介绍 React 中的测试方法和最佳实践。
测试工具对比
Vue 的测试工具
在 Vue 生态中,我们通常使用:
- Vue Test Utils:官方的组件测试工具
- Jest:单元测试框架
- Cypress:端到端测试工具
// Vue 组件测试示例
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter.vue', () => {
it('increments count when button is clicked', async () => {
const wrapper = mount(Counter);
expect(wrapper.text()).toContain('0');
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('1');
});
});
React 的测试工具
在 React 生态中,我们主要使用:
- React Testing Library:官方推荐的测试工具
- Jest:单元测试框架
- Cypress:端到端测试工具
// React 组件测试示例
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
it('increments count when button is clicked', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(screen.getByText('1')).toBeInTheDocument();
});
});
主要区别:
- 测试理念
- Vue Test Utils 偏向实现细节
- React Testing Library 偏向用户行为
- API 设计
- Vue 使用 wrapper API
- React 使用 DOM API
- 查询方式
- Vue 可以直接访问组件实例
- React 推荐使用可访问性查询
组件测试
1. 基础组件测试
// Button.tsx
function Button({ onClick, children, disabled }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="btn"
>
{children}
</button>
);
}
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('can be disabled', () => {
const handleClick = jest.fn();
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
2. 表单组件测试
// LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
const mockSubmit = jest.fn();
beforeEach(() => {
mockSubmit.mockClear();
});
it('validates required fields', async () => {
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/username is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
it('validates email format', async () => {
render(<LoginForm onSubmit={mockSubmit} />);
await userEvent.type(
screen.getByLabelText(/email/i),
'invalid-email'
);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(await screen.findByText(/invalid email format/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
it('submits form with valid data', async () => {
render(<LoginForm onSubmit={mockSubmit} />);
await userEvent.type(
screen.getByLabelText(/email/i),
'test@example.com'
);
await userEvent.type(
screen.getByLabelText(/password/i),
'password123'
);
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
});
3. 异步组件测试
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserProfile from './UserProfile';
const server = setupServer(
rest.get('/api/user/:id', (req, res, ctx) => {
return res(
ctx.json({
id: 1,
name: 'John Doe',
email: 'john@example.com'
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserProfile', () => {
it('displays loading state initially', () => {
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data after successful fetch', async () => {
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
it('handles error state', async () => {
server.use(
rest.get('/api/user/:id', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Hook 测试
1. 自定义 Hook 测试
// useCounter.ts
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
2. Context Hook 测试
// useAuth.test.tsx
import { renderHook, act } from '@testing-library/react-hooks';
import { AuthProvider, useAuth } from './useAuth';
describe('useAuth', () => {
const wrapper = ({ children }) => (
<AuthProvider>{children}</AuthProvider>
);
it('provides authentication state', () => {
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('handles login', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await act(async () => {
await result.current.login('test@example.com', 'password');
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual({
email: 'test@example.com'
});
});
it('handles logout', async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await act(async () => {
await result.current.login('test@example.com', 'password');
await result.current.logout();
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
});
集成测试
1. 路由测试
// App.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import App from './App';
describe('App routing', () => {
it('navigates to different pages', () => {
const history = createMemoryHistory();
render(
<Router history={history}>
<App />
</Router>
);
// 首页
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
// 导航到关于页
fireEvent.click(screen.getByText(/about/i));
expect(screen.getByText(/about us/i)).toBeInTheDocument();
// 导航到用户页
fireEvent.click(screen.getByText(/users/i));
expect(screen.getByText(/user list/i)).toBeInTheDocument();
});
it('handles 404 pages', () => {
const history = createMemoryHistory();
history.push('/invalid-route');
render(
<Router history={history}>
<App />
</Router>
);
expect(screen.getByText(/404/i)).toBeInTheDocument();
});
});
2. Redux 集成测试
// store.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './reducers';
import TodoList from './TodoList';
describe('TodoList with Redux', () => {
let store;
beforeEach(() => {
store = configureStore({
reducer: rootReducer,
preloadedState: {
todos: []
}
});
});
it('adds new todo', () => {
render(
<Provider store={store}>
<TodoList />
</Provider>
);
const input = screen.getByPlaceholderText(/add todo/i);
fireEvent.change(input, { target: { value: 'New Todo' } });
fireEvent.click(screen.getByText(/add/i));
expect(screen.getByText('New Todo')).toBeInTheDocument();
expect(store.getState().todos).toHaveLength(1);
});
it('toggles todo completion', () => {
store = configureStore({
reducer: rootReducer,
preloadedState: {
todos: [
{ id: 1, text: 'Test Todo', completed: false }
]
}
});
render(
<Provider store={store}>
<TodoList />
</Provider>
);
fireEvent.click(screen.getByText('Test Todo'));
expect(store.getState().todos[0].completed).toBe(true);
});
});
端到端测试
使用 Cypress
// cypress/integration/auth.spec.js
describe('Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('successfully logs in', () => {
cy.get('[data-testid="email-input"]')
.type('test@example.com');
cy.get('[data-testid="password-input"]')
.type('password123');
cy.get('[data-testid="login-button"]')
.click();
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-profile"]')
.should('contain', 'test@example.com');
});
it('displays validation errors', () => {
cy.get('[data-testid="login-button"]')
.click();
cy.get('[data-testid="email-error"]')
.should('be.visible')
.and('contain', 'Email is required');
cy.get('[data-testid="password-error"]')
.should('be.visible')
.and('contain', 'Password is required');
});
it('handles invalid credentials', () => {
cy.get('[data-testid="email-input"]')
.type('wrong@example.com');
cy.get('[data-testid="password-input"]')
.type('wrongpassword');
cy.get('[data-testid="login-button"]')
.click();
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', 'Invalid credentials');
});
});
测试最佳实践
测试策略
- 遵循测试金字塔
- 关注用户行为
- 避免测试实现细节
- 保持测试简单
代码组织
- 合理组织测试文件
- 使用测试工具函数
- 避免重复代码
- 维护测试数据
性能考虑
- 合理使用 mock
- 避免不必要的等待
- 优化测试运行时间
- 并行运行测试
小结
React 测试的特点:
- 行为驱动测试
- 可访问性优先
- 组件化测试
- 工具链完善
从 Vue 到 React 的转变:
- 适应新的测试理念
- 掌握测试工具
- 建立测试意识
- 实践测试策略
开发建议:
- 先写测试再实现
- 保持测试简单
- 关注测试覆盖
- 持续维护测试
下一篇文章,我们将深入探讨 React 的部署和持续集成策略,帮助你构建完整的开发流程。
如果觉得这篇文章对你有帮助,别忘了点个赞 👍