Skip to content

异步测试

概述

异步操作是现代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、定时器等异步模式的测试方法,你可以编写可靠的异步代码测试,确保应用的稳定性和可靠性。