Testing Library 异步测试 #
异步测试概述 #
React 应用中充满了异步操作:数据获取、定时器、动画等。Testing Library 提供了多种工具来处理这些异步场景:
text
┌─────────────────────────────────────────────────────────────┐
│ 异步测试工具 │
├─────────────────────────────────────────────────────────────┤
│ │
│ findBy* 查询 │
│ ├── 异步获取元素 │
│ ├── 自动等待和重试 │
│ └── 返回 Promise │
│ │
│ waitFor 工具 │
│ ├── 等待任意条件成立 │
│ ├── 可配置超时和重试 │
│ └── 灵活的断言包装 │
│ │
│ waitForElementToBeRemoved │
│ ├── 等待元素消失 │
│ └── 适用于加载状态 │
│ │
│ act 工具 │
│ ├── 包装状态更新 │
│ └── 确保更新完成 │
│ │
└─────────────────────────────────────────────────────────────┘
findBy 查询 #
基本用法 #
jsx
function AsyncComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
if (!data) return <div>Loading...</div>;
return <div>{data}</div>;
}
test('findBy waits for element', async () => {
render(<AsyncComponent />);
// 初始状态
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待异步内容
const dataElement = await screen.findByText('Loaded data');
expect(dataElement).toBeInTheDocument();
});
findBy 与 getBy 对比 #
jsx
test('getBy vs findBy', async () => {
render(<AsyncComponent />);
// ❌ getBy 无法等待异步内容
// screen.getByText('Loaded data'); // Error: element not found
// ✅ findBy 自动等待
await screen.findByText('Loaded data');
});
findBy 选项 #
jsx
test('findBy options', async () => {
render(<AsyncComponent />);
// 设置超时时间
await screen.findByText('Loaded data', {}, { timeout: 5000 });
// 设置查询选项
await screen.findByText('Loaded data', { exact: false });
});
findAllBy 用法 #
jsx
function AsyncList() {
const [items, setItems] = useState([]);
useEffect(() => {
fetchItems().then(setItems);
}, []);
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
test('findAllBy returns array', async () => {
render(<AsyncList />);
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(3);
});
waitFor 函数 #
基本用法 #
jsx
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => setCount(1), 100);
return () => clearTimeout(timer);
}, []);
return <div>Count: {count}</div>;
}
test('waitFor waits for condition', async () => {
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
waitFor 选项 #
jsx
test('waitFor options', async () => {
render(<SlowComponent />);
await waitFor(
() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
},
{
timeout: 5000, // 总超时时间
interval: 50, // 检查间隔
onTimeout: (error) => { // 超时回调
console.log('Timeout!', error);
},
}
);
});
多个断言 #
jsx
test('waitFor with multiple assertions', async () => {
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', 'avatar.jpg');
});
});
waitFor 与 findBy 选择 #
jsx
test('when to use waitFor vs findBy', async () => {
render(<AsyncComponent />);
// ✅ 使用 findBy - 简单等待元素出现
await screen.findByText('Loaded');
// ✅ 使用 waitFor - 复杂条件或多个断言
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeEnabled();
});
// ✅ 使用 waitFor - 断言元素不存在
await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
});
waitForElementToBeRemoved #
基本用法 #
jsx
function LoadingComponent() {
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData().finally(() => setLoading(false));
}, []);
if (loading) return <div data-testid="loading">Loading...</div>;
return <div>Content loaded</div>;
}
test('waitForElementToBeRemoved', async () => {
render(<LoadingComponent />);
expect(screen.getByTestId('loading')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.getByTestId('loading'));
expect(screen.getByText('Content loaded')).toBeInTheDocument();
});
使用查询函数 #
jsx
test('waitForElementToBeRemoved with query', async () => {
render(<LoadingComponent />);
// 使用 queryBy 避免元素不存在时报错
await waitForElementToBeRemoved(() => screen.queryByTestId('loading'));
});
选项配置 #
jsx
test('waitForElementToBeRemoved options', async () => {
render(<SlowLoadingComponent />);
await waitForElementToBeRemoved(
() => screen.getByTestId('loading'),
{ timeout: 10000 }
);
});
act 函数 #
什么是 act #
act 确保 React 状态更新在断言之前完成:
jsx
import { act } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
何时需要 act #
jsx
test('act for state updates', async () => {
const { container } = render(<Counter />);
// 大多数情况下不需要手动使用 act
// user-event 和 findBy 已自动包装 act
// 需要手动 act 的情况:
// 1. 直接调用状态更新函数
act(() => {
// 某些直接的状态操作
});
// 2. 使用定时器
act(() => {
jest.advanceTimersByTime(1000);
});
// 3. 异步操作
await act(async () => {
await someAsyncOperation();
});
});
act 警告处理 #
jsx
test('avoiding act warnings', async () => {
// ❌ 可能产生警告
render(<AsyncComponent />);
// Warning: An update to Component inside a test was not wrapped in act(...)
// ✅ 使用 findBy 自动处理
render(<AsyncComponent />);
await screen.findByText('Loaded');
// ✅ 使用 waitFor 自动处理
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});
数据获取测试 #
模拟 API 请求 #
jsx
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
test('fetches and displays users', async () => {
// 模拟 fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]),
})
);
render(<UserList />);
// 初始加载状态
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待数据加载
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});
测试错误状态 #
jsx
test('handles fetch error', async () => {
global.fetch = jest.fn(() => Promise.reject(new Error('Network error')));
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Error: Network error')).toBeInTheDocument();
});
});
使用 MSW 模拟 #
jsx
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('with MSW', async () => {
render(<UserList />);
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
});
定时器测试 #
使用 Jest 假定时器 #
jsx
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds((s) => s + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Seconds: {seconds}</div>;
}
test('timer advances correctly', () => {
jest.useFakeTimers();
render(<Timer />);
expect(screen.getByText('Seconds: 0')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('Seconds: 1')).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(5000);
});
expect(screen.getByText('Seconds: 6')).toBeInTheDocument();
jest.useRealTimers();
});
测试延迟操作 #
jsx
function DelayedButton() {
const [clicked, setClicked] = useState(false);
const handleClick = () => {
setTimeout(() => setClicked(true), 1000);
};
return (
<button onClick={handleClick}>
{clicked ? 'Clicked!' : 'Click me'}
</button>
);
}
test('delayed button click', () => {
jest.useFakeTimers();
render(<DelayedButton />);
const button = screen.getByRole('button');
fireEvent.click(button);
// 还未更新
expect(button).toHaveTextContent('Click me');
// 快进时间
act(() => {
jest.runAllTimers();
});
expect(button).toHaveTextContent('Clicked!');
jest.useRealTimers();
});
测试防抖 #
jsx
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
if (query) onSearch(query);
}, 500);
return () => clearTimeout(timer);
}, [query, onSearch]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
);
}
test('debounced search', async () => {
jest.useFakeTimers();
const mockSearch = jest.fn();
render(<SearchInput onSearch={mockSearch} />);
const input = screen.getByPlaceholderText('Search');
fireEvent.change(input, { target: { value: 'test' } });
// 还未触发搜索
expect(mockSearch).not.toHaveBeenCalled();
// 快进到防抖时间
act(() => {
jest.advanceTimersByTime(500);
});
expect(mockSearch).toHaveBeenCalledWith('test');
jest.useRealTimers();
});
异步更新测试 #
状态更新 #
jsx
function AsyncCounter() {
const [count, setCount] = useState(0);
const increment = () => {
Promise.resolve().then(() => setCount(c => c + 1));
};
return (
<div>
<span>{count}</span>
<button onClick={increment}>Increment</button>
</div>
);
}
test('async state update', async () => {
const user = userEvent.setup();
render(<AsyncCounter />);
await user.click(screen.getByRole('button'));
// 等待状态更新
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument();
});
});
Promise 链 #
jsx
function DataLoader() {
const [data, setData] = useState(null);
const load = async () => {
const result1 = await fetchStep1();
const result2 = await fetchStep2(result1);
setData(result2);
};
return (
<div>
<button onClick={load}>Load</button>
{data && <span>{data}</span>}
</div>
);
}
test('promise chain', async () => {
const user = userEvent.setup();
render(<DataLoader />);
await user.click(screen.getByRole('button', { name: /load/i }));
await screen.findByText('Final result');
});
加载状态测试 #
加载指示器 #
jsx
function DataLoader() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const load = async () => {
setLoading(true);
try {
const result = await fetchData();
setData(result);
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={load} disabled={loading}>
{loading ? 'Loading...' : 'Load Data'}
</button>
{data && <div>{data}</div>}
</div>
);
}
test('loading state', async () => {
const user = userEvent.setup();
render(<DataLoader />);
const button = screen.getByRole('button');
await user.click(button);
// 加载中
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
// 加载完成
await waitFor(() => {
expect(button).toBeEnabled();
expect(button).toHaveTextContent('Load Data');
});
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
骨架屏 #
jsx
function SkeletonLoader() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then((result) => {
setData(result);
setLoading(false);
});
}, []);
if (loading) {
return <div data-testid="skeleton">Loading...</div>;
}
return <div>{data}</div>;
}
test('skeleton disappears', async () => {
render(<SkeletonLoader />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.getByTestId('skeleton'));
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
轮询和重试 #
轮询数据 #
jsx
function PollingComponent() {
const [status, setStatus] = useState('pending');
useEffect(() => {
const interval = setInterval(async () => {
const result = await checkStatus();
setStatus(result);
if (result === 'completed') {
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Status: {status}</div>;
}
test('polling until complete', async () => {
jest.useFakeTimers();
let callCount = 0;
global.checkStatus = jest.fn(() => {
callCount++;
return Promise.resolve(callCount >= 3 ? 'completed' : 'pending');
});
render(<PollingComponent />);
expect(screen.getByText('Status: pending')).toBeInTheDocument();
// 快进第一次轮询
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('Status: pending')).toBeInTheDocument();
// 快进到完成
await act(async () => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Status: completed')).toBeInTheDocument();
jest.useRealTimers();
});
并发测试 #
并行请求 #
jsx
function ParallelLoader() {
const [results, setResults] = useState([]);
useEffect(() => {
Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]).then(([user, posts, comments]) => {
setResults([user, posts, comments]);
});
}, []);
if (results.length === 0) return <div>Loading...</div>;
return (
<div>
<div data-testid="user">{results[0].name}</div>
<div data-testid="posts">{results[1].length} posts</div>
<div data-testid="comments">{results[2].length} comments</div>
</div>
);
}
test('parallel requests', async () => {
render(<ParallelLoader />);
// 等待所有数据加载
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('John');
expect(screen.getByTestId('posts')).toHaveTextContent('5 posts');
expect(screen.getByTestId('comments')).toHaveTextContent('10 comments');
});
});
最佳实践 #
推荐做法 #
jsx
// ✅ 使用 findBy 等待元素
await screen.findByText('Loaded');
// ✅ 使用 waitFor 复杂条件
await waitFor(() => {
expect(element).toBeInTheDocument();
});
// ✅ 设置合理的超时
await screen.findByText('Slow content', {}, { timeout: 5000 });
// ✅ 清理定时器
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
避免做法 #
jsx
// ❌ 使用固定的等待时间
await new Promise(resolve => setTimeout(resolve, 1000));
// ❌ 不处理异步更新
render(<AsyncComponent />);
expect(screen.getByText('Loaded')); // 可能失败
// ❌ 超时时间过短
await screen.findByText('Loaded', {}, { timeout: 100 }); // 可能不够
// ❌ 忘记清理假定时器
// jest.useRealTimers() 缺失
调试技巧 #
调试异步问题 #
jsx
test('debug async issues', async () => {
const { debug } = render(<AsyncComponent />);
// 打印当前 DOM
debug();
// 在 waitFor 中调试
await waitFor(() => {
debug(); // 每次重试都打印
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
// 使用 onTimeout
await waitFor(
() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
},
{
onTimeout: (error) => {
debug();
console.log('Current DOM:', document.body.innerHTML);
throw error;
},
}
);
});
下一步 #
现在你已经掌握了异步测试,接下来学习 React Hooks 测试 了解如何测试自定义 Hooks!
最后更新:2026-03-28