Appearance
异步测试
概述
异步操作是现代Web应用的核心,包括API调用、数据库查询、文件操作等。正确测试异步代码对确保应用可靠性至关重要。本文将全面介绍如何测试Promise、async/await、回调函数以及React中的异步组件。
Promise测试
基础Promise测试
typescript
describe('Promise tests', () => {
it('should resolve promise', () => {
const promise = Promise.resolve('success');
return promise.then(result => {
expect(result).toBe('success');
});
});
it('should reject promise', () => {
const promise = Promise.reject(new Error('failure'));
return promise.catch(error => {
expect(error.message).toBe('failure');
});
});
// ✅ 使用return确保Jest等待Promise
it('must return promise', () => {
return fetchData().then(data => {
expect(data).toBeDefined();
});
});
// ❌ 忘记return会导致测试在Promise完成前结束
it('bad async test', () => {
fetchData().then(data => {
expect(data).toBeDefined(); // 可能不会执行
});
});
});.resolves/.rejects匹配器
typescript
describe('Promise matchers', () => {
it('should use .resolves', () => {
return expect(fetchUser(1)).resolves.toEqual({
id: 1,
name: 'John',
});
});
it('should use .rejects', () => {
return expect(fetchUser(-1)).rejects.toThrow('User not found');
});
it('should combine with other matchers', () => {
return expect(fetchUsers()).resolves.toHaveLength(3);
});
it('should check rejection reason', () => {
return expect(deleteUser(1)).rejects.toMatchObject({
code: 'PERMISSION_DENIED',
message: expect.stringContaining('permission'),
});
});
});async/await测试
基础async/await
typescript
describe('async/await tests', () => {
it('should fetch user', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('id', 1);
expect(user).toHaveProperty('name');
});
it('should handle async errors', async () => {
await expect(fetchUser(-1)).rejects.toThrow('User not found');
});
it('should use try/catch', async () => {
try {
await deleteUser(1);
// 如果没有抛错,测试应该失败
fail('Expected error to be thrown');
} catch (error) {
expect(error.message).toContain('permission');
}
});
});expect.assertions
typescript
describe('async assertions count', () => {
it('should ensure assertions are called', async () => {
expect.assertions(2); // 确保执行2个断言
try {
await riskyOperation();
} catch (error) {
expect(error).toBeDefined();
expect(error.code).toBe('ERROR');
}
});
it('should verify promise resolution', async () => {
expect.assertions(1);
const data = await fetchData();
expect(data).toBeTruthy();
});
});并发异步测试
typescript
describe('Concurrent async operations', () => {
it('should handle Promise.all', async () => {
const [user1, user2, user3] = await Promise.all([
fetchUser(1),
fetchUser(2),
fetchUser(3),
]);
expect(user1.id).toBe(1);
expect(user2.id).toBe(2);
expect(user3.id).toBe(3);
});
it('should handle Promise.race', async () => {
const fastest = await Promise.race([
fetchUser(1),
fetchUser(2),
]);
expect([1, 2]).toContain(fastest.id);
});
it('should handle Promise.allSettled', async () => {
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(-1), // 会失败
fetchUser(2),
]);
expect(results[0].status).toBe('fulfilled');
expect(results[1].status).toBe('rejected');
expect(results[2].status).toBe('fulfilled');
});
});回调函数测试
基础回调测试
typescript
describe('Callback tests', () => {
it('should handle callback', (done) => {
function fetchData(callback: (data: string) => void) {
setTimeout(() => {
callback('data');
}, 100);
}
fetchData((data) => {
try {
expect(data).toBe('data');
done();
} catch (error) {
done(error);
}
});
});
it('should handle error callback', (done) => {
function fetchData(callback: (error: Error | null, data?: string) => void) {
setTimeout(() => {
callback(new Error('failed'));
}, 100);
}
fetchData((error, data) => {
try {
expect(error).toBeDefined();
expect(error?.message).toBe('failed');
done();
} catch (err) {
done(err);
}
});
});
});回调转Promise
typescript
function promisify<T>(fn: (callback: (error: Error | null, result?: T) => void) => void): Promise<T> {
return new Promise((resolve, reject) => {
fn((error, result) => {
if (error) reject(error);
else resolve(result!);
});
});
}
describe('Promisified callbacks', () => {
it('should convert callback to promise', async () => {
const result = await promisify<string>(callback => {
setTimeout(() => callback(null, 'success'), 100);
});
expect(result).toBe('success');
});
});React异步组件测试
数据获取组件
typescript
// UserProfile.tsx
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>No user found</div>;
return <div>{user.name}</div>;
}
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
jest.mock('./api');
describe('UserProfile', () => {
it('should display loading state', () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should display user data', async () => {
(fetchUser as jest.Mock).mockResolvedValue({
id: 1,
name: 'John',
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
});
it('should display error message', async () => {
(fetchUser as jest.Mock).mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});waitFor使用
typescript
describe('waitFor', () => {
it('should wait for condition', async () => {
render(<AsyncComponent />);
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});
it('should use custom timeout', async () => {
render(<SlowComponent />);
await waitFor(
() => {
expect(screen.getByText('Ready')).toBeInTheDocument();
},
{ timeout: 5000 }
);
});
it('should check multiple conditions', async () => {
render(<ComplexComponent />);
await waitFor(() => {
expect(screen.getByRole('button')).not.toBeDisabled();
expect(screen.getByText('Ready')).toBeInTheDocument();
});
});
});waitForElementToBeRemoved
typescript
describe('waitForElementToBeRemoved', () => {
it('should wait for loading to be removed', async () => {
render(<DataComponent />);
const loader = screen.getByText('Loading...');
await waitForElementToBeRemoved(loader);
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
it('should wait for multiple elements', async () => {
render(<MultiLoader />);
const loaders = screen.getAllByTestId('loader');
await waitForElementToBeRemoved(loaders);
expect(screen.queryByTestId('loader')).not.toBeInTheDocument();
});
});findBy查询
typescript
describe('findBy queries', () => {
it('should use findByText', async () => {
render(<AsyncComponent />);
// findBy自动等待元素出现
const element = await screen.findByText('Async content');
expect(element).toBeInTheDocument();
});
it('should use findAllBy', async () => {
render(<AsyncList />);
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(5);
});
it('should handle timeout', async () => {
render(<NeverLoads />);
await expect(
screen.findByText('Never appears', {}, { timeout: 1000 })
).rejects.toThrow();
});
});定时器测试
假定时器
typescript
describe('Timer tests', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should handle setTimeout', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('should handle setInterval', () => {
const callback = jest.fn();
setInterval(callback, 100);
jest.advanceTimersByTime(500);
expect(callback).toHaveBeenCalledTimes(5);
});
it('should run all timers', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
setTimeout(callback1, 1000);
setTimeout(callback2, 2000);
jest.runAllTimers();
expect(callback1).toHaveBeenCalled();
expect(callback2).toHaveBeenCalled();
});
});与React组件结合
typescript
function DelayedMessage() {
const [message, setMessage] = useState('');
useEffect(() => {
const timer = setTimeout(() => {
setMessage('Hello after 1s');
}, 1000);
return () => clearTimeout(timer);
}, []);
return <div>{message}</div>;
}
describe('DelayedMessage', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should display message after delay', () => {
render(<DelayedMessage />);
expect(screen.queryByText('Hello after 1s')).not.toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText('Hello after 1s')).toBeInTheDocument();
});
});网络请求测试
Axios Mock
typescript
import axios from 'axios';
import { render, screen, waitFor } from '@testing-library/react';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
axios.get('/api/data').then(response => {
setData(response.data);
});
}, []);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
describe('DataFetcher', () => {
it('should fetch and display data', async () => {
mockedAxios.get.mockResolvedValue({
data: { message: 'Success' },
});
render(<DataFetcher />);
await waitFor(() => {
expect(screen.getByText(/"message":"Success"/)).toBeInTheDocument();
});
expect(mockedAxios.get).toHaveBeenCalledWith('/api/data');
});
it('should handle request error', async () => {
mockedAxios.get.mockRejectedValue(new Error('Network error'));
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(<DataFetcher />);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled();
});
consoleSpy.mockRestore();
});
});Fetch Mock
typescript
global.fetch = jest.fn();
describe('Fetch tests', () => {
beforeEach(() => {
(global.fetch as jest.Mock).mockClear();
});
it('should fetch data', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ data: 'test' }),
});
const result = await fetchData();
expect(global.fetch).toHaveBeenCalledWith('/api/data');
expect(result).toEqual({ data: 'test' });
});
it('should handle fetch error', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
});
await expect(fetchData()).rejects.toThrow('Not Found');
});
});实战案例
1. 搜索组件
typescript
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
setLoading(true);
const timer = setTimeout(() => {
searchAPI(query)
.then(setResults)
.finally(() => setLoading(false));
}, 300); // 防抖
return () => clearTimeout(timer);
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Searching...</div>}
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
);
}
describe('SearchComponent', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should debounce search', async () => {
(searchAPI as jest.Mock).mockResolvedValue(['result1', 'result2']);
const user = userEvent.setup({ delay: null });
render(<SearchComponent />);
const input = screen.getByPlaceholderText('Search...');
await user.type(input, 'test');
// 快速输入不应立即搜索
expect(searchAPI).not.toHaveBeenCalled();
// 等待防抖时间
act(() => {
jest.advanceTimersByTime(300);
});
await waitFor(() => {
expect(searchAPI).toHaveBeenCalledWith('test');
});
});
});2. 无限滚动
typescript
function InfiniteScroll() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
const newItems = await fetchItems(page);
setItems(prev => [...prev, ...newItems]);
setPage(p => p + 1);
setHasMore(newItems.length > 0);
setLoading(false);
}, [page, loading, hasMore]);
return (
<div>
{items.map(item => <div key={item.id}>{item.name}</div>)}
{loading && <div>Loading more...</div>}
<button onClick={loadMore} disabled={loading || !hasMore}>
Load More
</button>
</div>
);
}
describe('InfiniteScroll', () => {
it('should load more items', async () => {
(fetchItems as jest.Mock)
.mockResolvedValueOnce([{ id: 1, name: 'Item 1' }])
.mockResolvedValueOnce([{ id: 2, name: 'Item 2' }]);
const user = userEvent.setup();
render(<InfiniteScroll />);
// 第一次加载
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// 加载更多
await user.click(screen.getByText('Load More'));
await waitFor(() => {
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
expect(fetchItems).toHaveBeenCalledTimes(2);
});
});3. 表单提交
typescript
function SubmitForm() {
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await submitFormData(new FormData(e.currentTarget));
setSuccess(true);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<button type="submit" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</button>
{success && <div>Success!</div>}
{error && <div>Error: {error}</div>}
</form>
);
}
describe('SubmitForm', () => {
it('should handle successful submission', async () => {
(submitFormData as jest.Mock).mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(<SubmitForm />);
await user.type(screen.getByRole('textbox'), 'John');
await user.click(screen.getByRole('button'));
expect(screen.getByText('Submitting...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Success!')).toBeInTheDocument();
});
});
it('should handle submission error', async () => {
(submitFormData as jest.Mock).mockRejectedValue(
new Error('Submission failed')
);
const user = userEvent.setup();
render(<SubmitForm />);
await user.type(screen.getByRole('textbox'), 'John');
await user.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});最佳实践
避免act警告
typescript
// ✅ 使用act包装状态更新
import { act } from '@testing-library/react';
test('avoid act warning', async () => {
render(<Component />);
await act(async () => {
await fetchData();
});
// 断言
});
// ✅ waitFor自动处理act
test('with waitFor', async () => {
render(<Component />);
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});清理副作用
typescript
describe('Cleanup', () => {
afterEach(() => {
jest.clearAllTimers();
jest.clearAllMocks();
});
it('should cleanup', async () => {
const { unmount } = render(<Component />);
// 测试逻辑
unmount();
// 验证清理
});
});错误边界测试
typescript
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>Error occurred</div>;
}
return this.props.children;
}
}
function BrokenComponent() {
throw new Error('Broken!');
}
test('error boundary', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>
);
expect(screen.getByText('Error occurred')).toBeInTheDocument();
consoleSpy.mockRestore();
});异步测试是React应用测试的核心部分。通过掌握Promise、async/await、定时器等异步模式的测试方法,你可以编写可靠的异步代码测试,确保应用的稳定性和可靠性。