Section 3 · Testing the UI
Testing Async UI
18 min3 min readCode playground
Lesson at a glance
Test UI that changes after data loads or fails, using findBy queries and waitFor instead of sleeps, and control the async dependency to test loading, success and error states.
- 1The wrong way: sleeping
- 2findBy: wait for an element
- 3waitFor: wait for anything else
- 4Controlling time from the test side
- 5When the default timeout isn’t enough
- 6In the playground
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The balances panel in Splitwise-lite loads data from the server. For a moment it says “Loading balances…”, then it shows the list, or, on a train in a tunnel, “Could not load balances.” with a “Try again” button. Each of those states is something a user sees, so each deserves a test. The difficulty is time: the DOM you want to check doesn’t exist yet when the test reaches the line that checks it.
The wrong way: sleeping
The first instinct is to wait a bit:
mountBalancePanel(document.body, loadBalances);
await new Promise((resolve) => setTimeout(resolve, 500)); // please be enough
expect(screen.getByRole('list', { name: 'Balances' })).toBeInTheDocument();On your laptop the data arrives in 5 ms and the test wastes 495. On a busy CI runner it sometimes takes 600, and the test fails for no reason. Multiply by a few hundred tests and you have a slow suite that is red every Tuesday. Wait for the condition, not for the clock.
findBy: wait for an element
findBy queries are getBy queries that retry. They return a promise that resolves as soon as the element exists, and reject if it doesn’t appear within a timeout (1,000 ms by default):
// @vitest-environment jsdom
import { afterEach, test, expect, vi } from 'vitest';
import { screen } from '@testing-library/dom';
import '@testing-library/jest-dom/vitest';
import { mountBalancePanel } from './balance-panel.js';
afterEach(() => {
document.body.innerHTML = '';
});
test('shows a loading message, then the balances', async () => {
const loadBalances = vi.fn().mockResolvedValue({ Ana: 500, Ben: -500 });
mountBalancePanel(document.body, loadBalances);
expect(screen.getByRole('status')).toHaveTextContent('Loading balances');
expect(await screen.findByRole('list', { name: 'Balances' })).toBeInTheDocument();
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});The test is fast when the code is fast and patient when it is slow, and it reads like the story of what the user sees. The last line matters too: a panel that shows the data but leaves “Loading…” on screen is a real bug.
waitFor: wait for anything else
waitFor(callback) runs the callback, and if it throws, runs it again every 50 ms until it passes or the timeout expires. Use it for conditions that aren’t “an element appeared”:
await waitFor(() => expect(loadBalances).toHaveBeenCalledTimes(2));Vitest has its own vi.waitFor that works the same way outside the DOM. For the reverse case, waitForElementToBeRemoved(() => screen.queryByRole('status')) waits for something to disappear.
Controlling time from the test side
findBy handles waiting. To test each state deterministically, you also control the dependency. Since loadBalances is passed in, a vi.fn can make it do anything:
mockResolvedValue(data)for success;mockRejectedValue(new Error('Offline'))for failure;- a promise you resolve by hand, to freeze the loading state:
test('keeps showing the loading message until data arrives', async () => {
let finish;
const loadBalances = vi.fn(() => new Promise((resolve) => { finish = resolve; }));
mountBalancePanel(document.body, loadBalances);
expect(screen.getByRole('status')).toHaveTextContent('Loading balances');
finish({ Ana: 0 });
expect(await screen.findByText('Ana is settled up')).toBeInTheDocument();
});For the retry flow, start with a rejecting mock, wait for the alert, switch the mock to resolve, click “Try again” once, then findBy the list. Each step is something a user does or sees, and nothing depends on how fast the machine is.
When the default timeout isn’t enough
findBy and waitFor give up after one second. That is plenty for code whose dependency you control, and if a component test needs longer, the usual reason is a real network call or a real timer that leaked in. Fix the leak first. When the wait really is legitimate, pass options: screen.findByRole('list', {}, { timeout: 3000 }) (the third argument is the wait options) or waitFor(callback, { timeout: 3000 }).
In React tests you’ll sometimes see a warning that an update “was not wrapped in act(…)”. It usually means a state update happened after the test stopped looking: some promise resolved late. The fix is almost never to wrap things in act by hand; it’s to await the final state the user would see, with a findBy query.
In the playground
The exercise starter adds waitFor(callback), findByRole(root, role, options) and findByText(root, text) to the query stand-ins. They poll every 20 ms and give up after 500 ms, so a test against a broken version fails quickly.
Next lesson brings this to React components, and to the network calls behind loadBalances.
Key takeaways
findByqueries return a promise that resolves when the element appears, retrying until a timeout (one second by default).waitFor(callback)reruns a callback until it stops throwing; use it for assertions that aren’t about an element appearing, such as a mock’s call count.- Never wait a fixed time with a sleep; wait for the condition you actually care about.
- Control the async dependency yourself (a
vi.fnthat resolves, rejects or stays pending) so loading, success, error and retry are each one deterministic test.