EduFlow
Testing JavaScript Apps
0%

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.

  1. 1The wrong way: sleeping
  2. 2findBy: wait for an element
  3. 3waitFor: wait for anything else
  4. 4Controlling time from the test side
  5. 5When the default timeout isn’t enough
  6. 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:

JavaScript
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):

src/balance-panel.test.js
// @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”:

JavaScript
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.

A timeline: the query is retried every 50 milliseconds; it fails while loading, then passes once the list renders, well before the 1000 millisecond timeout. 0 ms 1000 ms timeout not there yet list found test continues immediately
findBy and waitFor poll until the condition holds

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:
JavaScript
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

  • findBy queries 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.fn that resolves, rejects or stays pending) so loading, success, error and retry are each one deterministic test.

Further reading

Check your understanding

3 questions

Question 1 of 3

The balance list appears after loadBalances() resolves. Which line waits for it correctly?

Question 2 of 3

What’s the problem with this?

JavaScript
await waitFor(() => {
  user.click(screen.getByRole('button', { name: 'Try again' }));
  expect(loadBalances).toHaveBeenCalledTimes(2);
});
Question 3 of 3

How do you test that the “Loading balances…” message is shown before the data arrives, without racing the promise?

Practice

Code playground

Loading, error and retry

mountBalancePanel(container, loadBalances) shows role="status" “Loading balances…” while loadBalances() is pending, then the Balances list. If the promise rejects, it shows role="alert" “Could not load balances.” and a Try again button that loads again.

The starter adds waitFor, findByRole and findByText to the stand-ins (they poll for up to 500 ms). Control loadBalances with vi.fn() and add tests until your suite catches four hidden bugs: one never shows the loading status, one leaves it on screen after the data arrives, one shows nothing on failure, and one whose Try again button does nothing.

function formatMoney(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

function renderBalances(container, balances) {
  const names = Object.keys(balances).sort((a, b) => a.localeCompare(b));
  if (names.length === 0) {
    const empty = document.createElement('p');
    empty.textContent = 'No expenses yet';
    container.replaceChildren(empty);
    return;
  }
  const list = document.createElement('ul');
  list.setAttribute('aria-label', 'Balances');
  for (const name of names) {
    const cents = balances[name];
    const item = document.createElement('li');
    item.textContent =
      cents > 0 ? `${name} is owed ${formatMoney(cents)}`
      : cents < 0 ? `${name} owes ${formatMoney(-cents)}`
      : `${name} is settled up`;
    list.append(item);
  }
  container.replaceChildren(list);
}

// Shows balances loaded with loadBalances() (returns a promise).
// - While loading: <p role="status">Loading balances…</p>
// - On success: the "Balances" list from renderBalances; the status is gone.
// - On failure: <p role="alert">Could not load balances.</p> and a
//   "Try again" button that calls loadBalances again.
function mountBalancePanel(container, loadBalances) {
  async function load() {
    container.innerHTML = '<p role="status">Loading balances…</p>';
    try {
      const balances = await loadBalances();
      renderBalances(container, balances);
    } catch {
      container.innerHTML =
        '<p role="alert">Could not load balances.</p><button type="button">Try again</button>';
      container.querySelector('button').addEventListener('click', load);
    }
  }
  load();
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real panel
  • Catches the version with no loading status
  • Catches the version that leaves the loading status on screen
  • Catches the version that shows nothing on failure
  • Catches the version whose Try again button does nothing