测试策略 #

测试环境配置 #

安装依赖 #

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