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