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