Anything that arrives later — a fetch, a transition, a lazy chunk, a debounce — needs a query that retries rather than one that looks once. findBy* polls the DOM through a MutationObserver until the query succeeds or the timeout expires (1000 ms by default), and its promise resolves inside act, so React 7,897 's queued work is flushed first: expect(await screen.findByRole('heading')).toHaveTextContent('Hello there'). waitFor(callback) is the general form, re-running a callback until it stops throwing, and suits things that are not DOM nodes. waitForElementToBeRemoved(() => screen.getByText('Loading...')) is the correct way to wait for a spinner to vanish; a bare waitFor asserting absence passes before the spinner has even appeared. Never await a fixed delay: slower than necessary on fast machines, flaky on slow ones.
Timers are the exception: waiting a real five seconds for a toast to hide is unacceptable, so replace the clock with one you advance by hand — inside act, since advancing it triggers a state update.
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test('disappears after five seconds', () => {
render(<Toast message="Saved" />);
expect(screen.getByRole('status')).toHaveTextContent('Saved');
act(() => vi.advanceTimersByTime(4999));
expect(screen.getByRole('status')).toBeInTheDocument();
act(() => vi.advanceTimersByTime(1));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});Five seconds of simulated waiting take 119 ms of real time. The 4999/1 split pins the boundary, so a later refactor that changes the delay fails loudly. Restore real timers in afterEach: a leaked fake clock makes every later test hang until Vitest 102,989 's own timeout fires, and the failure then appears in an unrelated file.