Context 测试 #

概述 #

React Context 提供了一种在组件树中共享数据的方式。测试 Context 时,我们需要确保 Provider 正确提供数据,Consumer 正确消费数据:

text
┌─────────────────────────────────────────────────────────────┐
│                     Context 测试策略                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 测试 Consumer 组件                                       │
│     ├── 使用测试 Provider 包装                               │
│     ├── 验证组件正确消费 Context                              │
│     └── 测试不同 Context 值                                  │
│                                                             │
│  2. 测试 Provider 组件                                       │
│     ├── 验证状态管理                                         │
│     ├── 测试 Context 更新                                    │
│     └── 验证子组件响应                                        │
│                                                             │
│  3. 自定义渲染器                                             │
│     ├── 创建可复用的测试包装器                                │
│     └── 简化测试代码                                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

基本 Context 测试 #

创建 Context #

jsx
const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = useCallback(() => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
  }, []);
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

测试 Consumer 组件 #

jsx
function ThemeDisplay() {
  const { theme } = useTheme();
  return <div data-testid="theme">{theme}</div>;
}

test('ThemeDisplay shows current theme', () => {
  render(
    <ThemeProvider>
      <ThemeDisplay />
    </ThemeProvider>
  );
  
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
});

测试不同 Context 值 #

jsx
test('ThemeDisplay with different themes', () => {
  const { rerender } = render(
    <ThemeContext.Provider value={{ theme: 'dark', toggleTheme: () => {} }}>
      <ThemeDisplay />
    </ThemeContext.Provider>
  );
  
  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
  
  rerender(
    <ThemeContext.Provider value={{ theme: 'light', toggleTheme: () => {} }}>
      <ThemeDisplay />
    </ThemeContext.Provider>
  );
  
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
});

测试 Provider 组件 #

测试状态更新 #

jsx
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <button onClick={toggleTheme}>Toggle</button>
    </div>
  );
}

test('ThemeProvider toggles theme', async () => {
  const user = userEvent.setup();
  
  render(
    <ThemeProvider>
      <ThemeToggle />
    </ThemeProvider>
  );
  
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
  
  await user.click(screen.getByRole('button', { name: /toggle/i }));
  
  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
  
  await user.click(screen.getByRole('button', { name: /toggle/i }));
  
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
});

测试嵌套 Provider #

jsx
const UserContext = createContext();
const ThemeContext = createContext();

function AppProviders({ children }) {
  const [user, setUser] = useState({ name: 'Guest' });
  const [theme, setTheme] = useState('light');
  
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function UserProfile() {
  const { user } = useContext(UserContext);
  const { theme } = useContext(ThemeContext);
  
  return (
    <div>
      <span data-testid="username">{user.name}</span>
      <span data-testid="theme">{theme}</span>
    </div>
  );
}

test('nested providers', () => {
  render(
    <AppProviders>
      <UserProfile />
    </AppProviders>
  );
  
  expect(screen.getByTestId('username')).toHaveTextContent('Guest');
  expect(screen.getByTestId('theme')).toHaveTextContent('light');
});

自定义渲染器 #

创建测试工具函数 #

jsx
const AuthContext = createContext();

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  
  const login = useCallback((userData) => {
    setUser(userData);
    setIsAuthenticated(true);
  }, []);
  
  const logout = useCallback(() => {
    setUser(null);
    setIsAuthenticated(false);
  }, []);
  
  return (
    <AuthContext.Provider value={{ user, isAuthenticated, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

function useAuth() {
  return useContext(AuthContext);
}

// 创建自定义渲染函数
function renderWithAuth(ui, { user = null, ...options } = {}) {
  const Wrapper = ({ children }) => (
    <AuthContext.Provider
      value={{
        user,
        isAuthenticated: !!user,
        login: jest.fn(),
        logout: jest.fn(),
      }}
    >
      {children}
    </AuthContext.Provider>
  );
  
  return render(ui, { wrapper: Wrapper, ...options });
}

使用自定义渲染器 #

jsx
function LoginButton() {
  const { isAuthenticated, login } = useAuth();
  
  if (isAuthenticated) {
    return <span>Logged in</span>;
  }
  
  return (
    <button onClick={() => login({ name: 'John' })}>
      Login
    </button>
  );
}

test('LoginButton with custom renderer', async () => {
  const user = userEvent.setup();
  
  // 未登录状态
  renderWithAuth(<LoginButton />);
  
  expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});

test('LoginButton shows logged in state', () => {
  // 已登录状态
  renderWithAuth(<LoginButton />, {
    user: { name: 'John' },
  });
  
  expect(screen.getByText('Logged in')).toBeInTheDocument();
});

完整的测试工具文件 #

jsx
const AllProviders = ({ children }) => {
  return (
    <AuthProvider>
      <ThemeProvider>
        <Router>
          {children}
        </Router>
      </ThemeProvider>
    </AuthProvider>
  );
};

function customRender(ui, options = {}) {
  return render(ui, { wrapper: AllProviders, ...options });
}

export * from '@testing-library/react';
export { customRender as render };

测试 Context 更新 #

测试登录流程 #

jsx
function LoginForm() {
  const { login } = useAuth();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    login({ email, name: 'User' });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
    </form>
  );
}

test('login updates context', async () => {
  const user = userEvent.setup();
  
  render(
    <AuthProvider>
      <LoginForm />
      <ProfileDisplay />
    </AuthProvider>
  );
  
  await user.type(screen.getByPlaceholderText('Email'), 'test@example.com');
  await user.type(screen.getByPlaceholderText('Password'), 'password');
  await user.click(screen.getByRole('button', { name: /login/i }));
  
  expect(screen.getByTestId('username')).toHaveTextContent('User');
});

测试 Context 值变化 #

jsx
function CounterProvider({ children }) {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  
  return (
    <CounterContext.Provider value={{ count, increment, decrement }}>
      {children}
    </CounterContext.Provider>
  );
}

function CounterDisplay() {
  const { count } = useContext(CounterContext);
  return <span data-testid="count">{count}</span>;
}

function CounterButtons() {
  const { increment, decrement } = useContext(CounterContext);
  return (
    <div>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

test('counter context updates across components', async () => {
  const user = userEvent.setup();
  
  render(
    <CounterProvider>
      <CounterDisplay />
      <CounterButtons />
    </CounterProvider>
  );
  
  expect(screen.getByTestId('count')).toHaveTextContent('0');
  
  await user.click(screen.getByText('+'));
  expect(screen.getByTestId('count')).toHaveTextContent('1');
  
  await user.click(screen.getByText('-'));
  expect(screen.getByTestId('count')).toHaveTextContent('0');
});

测试 Context 默认值 #

jsx
const DataContext = createContext({ data: null, loading: false });

function DataConsumer() {
  const { data, loading } = useContext(DataContext);
  
  if (loading) return <div>Loading...</div>;
  if (!data) return <div>No data</div>;
  
  return <div>{data}</div>;
}

test('uses default context value', () => {
  render(<DataConsumer />);
  
  expect(screen.getByText('No data')).toBeInTheDocument();
});

测试 Context 错误处理 #

jsx
function useRequiredContext() {
  const context = useContext(RequiredContext);
  
  if (!context) {
    throw new Error('useRequiredContext must be used within Provider');
  }
  
  return context;
}

function ComponentUsingRequiredContext() {
  useRequiredContext();
  return <div>OK</div>;
}

test('throws error when used outside provider', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
  
  expect(() => {
    render(<ComponentUsingRequiredContext />);
  }).toThrow('useRequiredContext must be used within Provider');
  
  spy.mockRestore();
});

Mock Context #

Mock Context Provider #

jsx
const mockAuthContext = {
  user: { name: 'Test User', email: 'test@example.com' },
  isAuthenticated: true,
  login: jest.fn(),
  logout: jest.fn(),
};

function renderWithMockAuth(ui) {
  return render(
    <AuthContext.Provider value={mockAuthContext}>
      {ui}
    </AuthContext.Provider>
  );
}

test('with mocked context', () => {
  renderWithMockAuth(<UserProfile />);
  
  expect(screen.getByText('Test User')).toBeInTheDocument();
});

动态 Mock Context #

jsx
function createMockContext(initialValue) {
  let value = initialValue;
  
  const mockProvider = ({ children }) => (
    <TestContext.Provider value={value}>
      {children}
    </TestContext.Provider>
  );
  
  const setValue = (newValue) => {
    value = newValue;
  };
  
  return { mockProvider, setValue };
}

test('dynamic mock context', () => {
  const { mockProvider, setValue } = createMockContext({ count: 0 });
  
  const { rerender } = render(
    <mockProvider>
      <CounterDisplay />
    </mockProvider>
  );
  
  expect(screen.getByTestId('count')).toHaveTextContent('0');
  
  setValue({ count: 5 });
  rerender(
    <mockProvider>
      <CounterDisplay />
    </mockProvider>
  );
  
  expect(screen.getByTestId('count')).toHaveTextContent('5');
});

测试异步 Context #

jsx
function AsyncDataProvider({ children }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    fetchData()
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);
  
  return (
    <AsyncDataContext.Provider value={{ data, loading, error }}>
      {children}
    </AsyncDataContext.Provider>
  );
}

function AsyncDataDisplay() {
  const { data, loading, error } = useContext(AsyncDataContext);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return <div>{data}</div>;
}

test('async context data loading', async () => {
  render(
    <AsyncDataProvider>
      <AsyncDataDisplay />
    </AsyncDataProvider>
  );
  
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.getByText('Loaded data')).toBeInTheDocument();
  });
});

测试 Context 与 Router 集成 #

jsx
const RouterContext = createContext();

function RouterProvider({ children }) {
  const [route, setRoute] = useState('/');
  
  const navigate = useCallback((path) => {
    setRoute(path);
  }, []);
  
  return (
    <RouterContext.Provider value={{ route, navigate }}>
      {children}
    </RouterContext.Provider>
  );
}

function Navigation() {
  const { route, navigate } = useContext(RouterContext);
  
  return (
    <nav>
      <span data-testid="current-route">{route}</span>
      <button onClick={() => navigate('/home')}>Home</button>
      <button onClick={() => navigate('/about')}>About</button>
    </nav>
  );
}

test('router context navigation', async () => {
  const user = userEvent.setup();
  
  render(
    <RouterProvider>
      <Navigation />
    </RouterProvider>
  );
  
  expect(screen.getByTestId('current-route')).toHaveTextContent('/');
  
  await user.click(screen.getByRole('button', { name: /home/i }));
  
  expect(screen.getByTestId('current-route')).toHaveTextContent('/home');
});

实战示例:购物车 Context #

jsx
const CartContext = createContext();

function CartProvider({ children }) {
  const [items, setItems] = useState([]);
  
  const addItem = useCallback((item) => {
    setItems((prev) => {
      const existing = prev.find((i) => i.id === item.id);
      if (existing) {
        return prev.map((i) =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...prev, { ...item, quantity: 1 }];
    });
  }, []);
  
  const removeItem = useCallback((itemId) => {
    setItems((prev) => prev.filter((i) => i.id !== itemId));
  }, []);
  
  const updateQuantity = useCallback((itemId, quantity) => {
    if (quantity <= 0) {
      removeItem(itemId);
      return;
    }
    setItems((prev) =>
      prev.map((i) => (i.id === itemId ? { ...i, quantity } : i))
    );
  }, [removeItem]);
  
  const clearCart = useCallback(() => {
    setItems([]);
  }, []);
  
  const total = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  
  return (
    <CartContext.Provider
      value={{ items, addItem, removeItem, updateQuantity, clearCart, total }}
    >
      {children}
    </CartContext.Provider>
  );
}

function useCart() {
  return useContext(CartContext);
}

function CartDisplay() {
  const { items, total } = useCart();
  
  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            {item.name} x {item.quantity} - ${item.price * item.quantity}
          </li>
        ))}
      </ul>
      <div data-testid="total">Total: ${total}</div>
    </div>
  );
}

function AddToCartButton({ product }) {
  const { addItem } = useCart();
  
  return (
    <button onClick={() => addItem(product)}>
      Add {product.name}
    </button>
  );
}

test('cart context functionality', async () => {
  const user = userEvent.setup();
  
  const product1 = { id: 1, name: 'Product 1', price: 10 };
  const product2 = { id: 2, name: 'Product 2', price: 20 };
  
  render(
    <CartProvider>
      <CartDisplay />
      <AddToCartButton product={product1} />
      <AddToCartButton product={product2} />
    </CartProvider>
  );
  
  expect(screen.getByTestId('total')).toHaveTextContent('Total: $0');
  
  await user.click(screen.getByRole('button', { name: /add product 1/i }));
  
  expect(screen.getByText('Product 1 x 1 - $10')).toBeInTheDocument();
  expect(screen.getByTestId('total')).toHaveTextContent('Total: $10');
  
  await user.click(screen.getByRole('button', { name: /add product 2/i }));
  
  expect(screen.getByText('Product 2 x 1 - $20')).toBeInTheDocument();
  expect(screen.getByTestId('total')).toHaveTextContent('Total: $30');
});

最佳实践 #

推荐做法 #

jsx
// ✅ 使用自定义渲染器
function renderWithProviders(ui, options) {
  return render(ui, { wrapper: AllProviders, ...options });
}

// ✅ 测试组件行为而非实现
test('user can log in', async () => {
  renderWithProviders(<LoginPage />);
  await user.click(screen.getByRole('button', { name: /login/i }));
  expect(screen.getByText('Welcome')).toBeInTheDocument();
});

// ✅ 测试不同 Context 值
test('shows admin panel for admin user', () => {
  renderWithProviders(<Dashboard />, { user: { role: 'admin' } });
  expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});

避免做法 #

jsx
// ❌ 在每个测试中重复 Provider
render(
  <ThemeProvider>
    <AuthProvider>
      <Component />
    </AuthProvider>
  </ThemeProvider>
);

// ❌ 直接测试 Context 值
expect(contextValue.theme).toBe('dark');

// ❌ 忘记处理错误边界
// 缺少错误处理

下一步 #

现在你已经掌握了 Context 测试,接下来学习 高级场景测试 了解更多复杂测试场景!

最后更新:2026-03-28