测试策略 #
测试环境配置 #
安装依赖 #
bash
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
Jest 配置 #
javascript
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
Setup 文件 #
javascript
import '@testing-library/jest-dom';
测试 Recoil 组件 #
基本测试 #
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import Counter from './Counter';
const renderWithRecoil = (component) => {
return render(
<RecoilRoot>
{component}
</RecoilRoot>
);
};
test('renders counter with initial value', () => {
renderWithRecoil(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
test('increments counter', () => {
renderWithRecoil(<Counter />);
fireEvent.click(screen.getByText('+'));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
初始化状态 #
jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoilRoot, atom, useRecoilState } from 'recoil';
const countState = atom({
key: 'countState',
default: 0,
});
function Counter() {
const [count, setCount] = useRecoilState(countState);
return (
<div>
<span>Count: {count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
const renderWithInitialState = (component, initialState) => {
return render(
<RecoilRoot initializeState={({ set }) => {
set(countState, initialState);
}}>
{component}
</RecoilRoot>
);
};
test('renders counter with custom initial value', () => {
renderWithInitialState(<Counter />, 10);
expect(screen.getByText('Count: 10')).toBeInTheDocument();
});
测试 Hooks #
测试自定义 Hook #
jsx
import { renderHook, act } from '@testing-library/react';
import { RecoilRoot, atom, useRecoilState } from 'recoil';
const countState = atom({
key: 'countState',
default: 0,
});
function useCounter() {
const [count, setCount] = useRecoilState(countState);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(0);
return { count, increment, decrement, reset };
}
const wrapper = ({ children }) => <RecoilRoot>{children}</RecoilRoot>;
test('useCounter hook', () => {
const { result } = renderHook(() => useCounter(), { wrapper });
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(2);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
测试异步状态 #
测试异步 Selector #
jsx
import { render, screen, waitFor } from '@testing-library/react';
import { RecoilRoot, atom, selector, useRecoilValue } from 'recoil';
const userIdState = atom({
key: 'userId',
default: 1,
});
const userQueryState = selector({
key: 'userQuery',
get: async ({ get }) => {
const userId = get(userIdState);
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});
function UserProfile() {
const user = useRecoilValue(userQueryState);
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'John', email: 'john@example.com' }),
})
);
});
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches and displays user data', async () => {
render(
<RecoilRoot>
<UserProfile />
</RecoilRoot>
);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
测试加载状态 #
jsx
import { Suspense } from 'react';
import { useRecoilValueLoadable } from 'recoil';
function UserProfileWithLoading() {
const userLoadable = useRecoilValueLoadable(userQueryState);
switch (userLoadable.state) {
case 'loading':
return <div>Loading...</div>;
case 'hasError':
return <div>Error: {userLoadable.contents.message}</div>;
case 'hasValue':
return (
<div>
<h2>{userLoadable.contents.name}</h2>
</div>
);
}
}
test('shows loading state', () => {
render(
<RecoilRoot>
<UserProfileWithLoading />
</RecoilRoot>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows user data after loading', async () => {
render(
<RecoilRoot>
<UserProfileWithLoading />
</RecoilRoot>
);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
测试组件交互 #
测试表单 #
jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RecoilRoot, atom, useRecoilState } from 'recoil';
const formState = atom({
key: 'formState',
default: {
username: '',
email: '',
},
});
function UserForm({ onSubmit }) {
const [form, setForm] = useRecoilState(formState);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(form);
};
return (
<form onSubmit={handleSubmit}>
<input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
placeholder="Username"
/>
<input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}
test('updates form state on input change', () => {
render(
<RecoilRoot>
<UserForm onSubmit={() => {}} />
</RecoilRoot>
);
fireEvent.change(screen.getByPlaceholderText('Username'), {
target: { value: 'john' },
});
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'john@example.com' },
});
expect(screen.getByPlaceholderText('Username')).toHaveValue('john');
expect(screen.getByPlaceholderText('Email')).toHaveValue('john@example.com');
});
test('submits form with correct data', () => {
const handleSubmit = jest.fn();
render(
<RecoilRoot>
<UserForm onSubmit={handleSubmit} />
</RecoilRoot>
);
fireEvent.change(screen.getByPlaceholderText('Username'), {
target: { value: 'john' },
});
fireEvent.change(screen.getByPlaceholderText('Email'), {
target: { value: 'john@example.com' },
});
fireEvent.click(screen.getByText('Submit'));
expect(handleSubmit).toHaveBeenCalledWith({
username: 'john',
email: 'john@example.com',
});
});
测试工具函数 #
创建测试工具 #
jsx
import { render } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
export function renderWithRecoil(ui, options = {}) {
const { initialState = {}, ...renderOptions } = options;
return render(
<RecoilRoot initializeState={({ set }) => {
Object.entries(initialState).forEach(([atomKey, value]) => {
set({ key: atomKey }, value);
});
}}>
{ui}
</RecoilRoot>,
renderOptions
);
}
使用测试工具 #
jsx
import { screen, fireEvent } from '@testing-library/react';
import { renderWithRecoil } from '../test-utils';
import Counter from './Counter';
test('counter with initial state', () => {
renderWithRecoil(<Counter />, {
initialState: {
countState: 10,
},
});
expect(screen.getByText('Count: 10')).toBeInTheDocument();
});
Mock 策略 #
Mock API 响应 #
jsx
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
beforeEach(() => {
jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockUser),
})
);
});
afterEach(() => {
jest.restoreAllMocks();
});
Mock Recoil 状态 #
jsx
import { useRecoilValue, useRecoilState } from 'recoil';
jest.mock('recoil', () => ({
...jest.requireActual('recoil'),
useRecoilValue: jest.fn(),
useRecoilState: jest.fn(),
}));
test('with mocked state', () => {
useRecoilValue.mockReturnValue('John');
render(<UserName />);
expect(screen.getByText('John')).toBeInTheDocument();
});
完整测试示例 #
jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RecoilRoot, atom, selector, useRecoilState, useRecoilValue } from 'recoil';
const todoListState = atom({
key: 'todoListState',
default: [],
});
const todoFilterState = atom({
key: 'todoFilterState',
default: 'all',
});
const filteredTodoListState = selector({
key: 'filteredTodoListState',
get: ({ get }) => {
const list = get(todoListState);
const filter = get(todoFilterState);
switch (filter) {
case 'completed':
return list.filter(t => t.completed);
case 'active':
return list.filter(t => !t.completed);
default:
return list;
}
},
});
function TodoApp() {
const [todos, setTodos] = useRecoilState(todoListState);
const [filter, setFilter] = useRecoilState(todoFilterState);
const filteredTodos = useRecoilValue(filteredTodoListState);
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
const toggleTodo = (id) => {
setTodos(todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
};
return (
<div>
<input
data-testid="todo-input"
onKeyDown={(e) => {
if (e.key === 'Enter') {
addTodo(e.target.value);
e.target.value = '';
}
}}
/>
<select
data-testid="filter-select"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<ul>
{filteredTodos.map(todo => (
<li
key={todo.id}
data-testid="todo-item"
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => toggleTodo(todo.id)}
>
{todo.text}
</li>
))}
</ul>
</div>
);
}
describe('TodoApp', () => {
test('adds a new todo', () => {
render(
<RecoilRoot>
<TodoApp />
</RecoilRoot>
);
const input = screen.getByTestId('todo-input');
fireEvent.keyDown(input, { key: 'Enter', target: { value: 'Buy milk' } });
expect(screen.getByText('Buy milk')).toBeInTheDocument();
});
test('toggles todo completion', () => {
render(
<RecoilRoot>
<TodoApp />
</RecoilRoot>
);
const input = screen.getByTestId('todo-input');
fireEvent.keyDown(input, { key: 'Enter', target: { value: 'Buy milk' } });
const todoItem = screen.getByTestId('todo-item');
expect(todoItem).not.toHaveStyle('text-decoration: line-through');
fireEvent.click(todoItem);
expect(todoItem).toHaveStyle('text-decoration: line-through');
});
test('filters todos', () => {
render(
<RecoilRoot>
<TodoApp />
</RecoilRoot>
);
const input = screen.getByTestId('todo-input');
fireEvent.keyDown(input, { key: 'Enter', target: { value: 'Buy milk' } });
fireEvent.keyDown(input, { key: 'Enter', target: { value: 'Walk dog' } });
const todoItems = screen.getAllByTestId('todo-item');
fireEvent.click(todoItems[0]);
const filterSelect = screen.getByTestId('filter-select');
fireEvent.change(filterSelect, { target: { value: 'active' } });
expect(screen.getAllByTestId('todo-item')).toHaveLength(1);
expect(screen.getByText('Walk dog')).toBeInTheDocument();
});
});
总结 #
测试策略的核心要点:
| 测试类型 | 工具 |
|---|---|
| 组件测试 | @testing-library/react |
| Hook 测试 | @testing-library/react-hooks |
| 异步测试 | waitFor, act |
| Mock | jest.fn, jest.spyOn |
下一步,让我们学习 常见问题,了解 Recoil 开发中的常见问题解答。
最后更新:2026-03-28