Testing From Zero

Testing UI Components with Testing Library

Queries by role, user-event, async UI, mocking the network with MSW, forms and error states.

Lesson 0213 min read#react-testing#msw#accessibility

On this page

  1. Words used in this lesson
  2. What Testing Library believes
  3. Setup: Vitest + jsdom + React
  4. Queries: find things like a user
  5. user-event: act like a person
  6. The components to test
  7. Mocking the network with MSW
  8. Async UI: findBy and waitFor
  9. Testing forms and error states
  10. What not to test
  11. Try it
  12. Interview questions
  13. Key takeaways
  14. What's next

In one line: test a UI component the way a user meets it. Render it, find things by their role and label (what a screen reader would announce), act with user-event, wait for async changes with findBy or waitFor, and fake only the network (with MSW). Do not test the internals of the component.

Lesson 1 covered unit tests and test doubles. This lesson applies the same ideas to React components with Testing Library. We run the tests in Vitest with a fake browser environment. For the wider frontend test strategy (what to cover at which level) see Frontend Depth Lesson 5. For tool questions that interviewers ask see Full-Stack Lesson 21. The next lesson, Lesson 3, moves to integration and API tests.

What I ran / checked. I ran every test below on Node.js v22.22.0 (Linux) with these exact versions, installed by npm i on 2026-10-11: vitest 5.0.3, jsdom 29.1.1, happy-dom 20.14.6, react and react-dom 19.3.0, @testing-library/react 16.3.3, @testing-library/dom 10.4.2, @testing-library/user-event 14.6.7, @testing-library/jest-dom 7.0.1, msw 2.15.0, @vitejs/plugin-react 6.1.2. Output is pasted as printed, with blank lines and long paths removed. Times are from one machine and are not a benchmark. I read the query priority guide in testing-library/testing-library-docs (docs/queries/about.mdx) and the guiding principle in the react-testing-library README. I read only the first lines of the MSW README. Other versions may behave differently.

Words used in this lesson

  • jsdom / happy-dom: JavaScript programs that imitate a browser's DOM, so tests can run in Node without a real browser. They are fast, but they do not draw pixels or do layout.
  • Render: put the component into a fake page so the test can look at it.
  • Query: a function that finds an element, such as getByRole.
  • Accessible name: the text a screen reader announces for an element (from its label, text or aria-label).
  • Role: what an element is for assistive technology (button, textbox, heading, alert).
  • user-event: a library that simulates what a user does (type, click, tab) as a real sequence of browser events.
  • MSW (Mock Service Worker): a library that intercepts network requests and answers them with responses you define. It sits below your code, so your code still calls fetch.

What Testing Library believes

Testing Library has one guiding principle, from its README: the more your tests resemble the way your software is used, the more confidence they can give you. So you:

  • find elements as a user would (by what they see and hear), not by class names or component names,
  • do what a user does (click, type), not call component methods or set state,
  • check what a user sees, not the state variables inside.

Same lesson as the brittle test in Lesson 0: test behaviour, not construction. You can change useState to useReducer and the tests still pass.

Setup: Vitest + jsdom + React

Create a project and install the packages (versions as in the note above):

TEXT
npm init -y && npm pkg set type=modulenpm i -D vitest jsdom react react-dom @testing-library/react @testing-library/dom \  @testing-library/user-event @testing-library/jest-dom msw @vitejs/plugin-react

@testing-library/dom is a peer dependency of @testing-library/react, so install it too. Then the Vitest config:

JavaScript
// vitest.config.jsimport { defineConfig } from 'vitest/config';import react from '@vitejs/plugin-react';export default defineConfig({  plugins: [react()],            // lets Vitest understand JSX  test: {    environment: 'jsdom',        // a fake browser page    setupFiles: ['./src/setup.js'],  },});

And a setup file that runs before each test file. Start with only jest-dom, which adds matchers like toBeInTheDocument():

JavaScript
// src/setup.jsimport '@testing-library/jest-dom/vitest';

A first component and test

JavaScript
// src/Counter.jsximport { useState } from 'react';export function Counter() {  const [n, setN] = useState(0);  return (    <div>      <p>Count: {n}</p>      <button onClick={() => setN(n + 1)}>Add one</button>    </div>  );}
JavaScript
// src/Counter.test.jsximport { describe, it, expect } from 'vitest';import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { Counter } from './Counter.jsx';describe('Counter', () => {  it('starts at zero', () => {    render(<Counter />);    expect(screen.getByText('Count: 0')).toBeInTheDocument();  });  it('adds one per click', async () => {    const user = userEvent.setup();    render(<Counter />);    await user.click(screen.getByRole('button', { name: 'Add one' }));    await user.click(screen.getByRole('button', { name: 'Add one' }));    expect(screen.getByText('Count: 2')).toBeInTheDocument();  });});

I ran it with this setup. It failed:

TEXT
$ npx vitest run FAIL  src/Counter.test.jsx > Counter > adds one per clickTestingLibraryElementError: Found multiple elements with the role "button" and name "Add one"      Tests  1 failed | 1 passed (2)

This is a trap worth knowing. The first test rendered a Counter and nothing removed it, so the second test saw two buttons. Testing Library only cleans up by itself when the test framework has a global afterEach. Vitest does not by default (its globals option is off). The fix is to call cleanup yourself:

JavaScript
// src/setup.jsimport '@testing-library/jest-dom/vitest';import { afterEach } from 'vitest';import { cleanup } from '@testing-library/react';afterEach(() => cleanup());   // unmount what each test rendered

Now both tests pass (Tests 2 passed (2)). That failure is also a good example of test isolation from Lesson 1: a test left something behind.

jsdom or happy-dom? Both imitate the DOM in Node. I ran the same 9 tests of this lesson with environment: 'happy-dom' (happy-dom 20.14.6) and they all passed. The whole run took 1.67 s with happy-dom and 2.54 s with jsdom (single runs on this machine, so only a hint). I did not compare their behaviour beyond these tests. If a test fails only in one, suspect a difference in the fake browser, and consider a real browser test (Lesson 4) for that case.

Queries: find things like a user

Every query comes in three families. They differ in what they do when the element is missing:

FamilyNot foundFound more than oneUse for
getBy...throwsthrowsan element that must be there now
queryBy...returns nullthrowschecking that something is absent
findBy...waits, then throwsthrowsan element that will appear later (async)

Add All (getAllBy...) when you expect several.

Which query first? Role before test id

Testing Library's docs give this priority order (from queries/about.mdx):

  1. Accessible to everyone: getByRole (top choice, usually with name), getByLabelText (best for form fields), getByPlaceholderText, getByText, getByDisplayValue.
  2. Semantic: getByAltText, getByTitle.
  3. Test IDs: getByTestId, only when you cannot match by role or text.

Why? A user (and a screen reader) finds a button by what it says and what it is. A data-testid is invisible to them. If your test can find the element by role and name, you know a real person can too. If you cannot, the page may be hard to use. A role query is also a free accessibility check, as the next section shows.

Here is the real error when a role query fails. The component has an icon button without a text label:

JavaScript
function IconButton() {  return <button onClick={() => {}}><svg aria-hidden="true" /></button>;   // no accessible name}// in the test:render(<IconButton />);screen.getByRole('button', { name: 'Close' });
TEXT
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Close"Here are the accessible roles:  button:  Name "":  <button />  --------------------------------------------------Ignored nodes: comments, script, style<body>  <div>    <button>      <svg aria-hidden="true" />    </button>  </div></body>

The error lists the roles it found and says the button has Name "". That is a real bug: a screen reader user hears just "button". The test failed for a good reason, and the fix is in the component (add aria-label="Close"), not in the test. This is the "accessibility queries as a bonus" idea: if getByRole(..., { name }) cannot find it, assistive technology probably cannot either.

A password input has no textbox role, so use getByLabelText('Password') for it. In my run, getByRole('heading', { level: 2, name: 'Create account' }), getByRole('textbox', { name: 'Email' }) and toHaveAccessibleName('Email') all found the SignupForm below.

When findBy times out, the error prints the page as text (<body><div><p>hello</p></div></body>) after "Unable to find an element with the text: bye.", which helps a lot.

user-event: act like a person

fireEvent sends one DOM event. user-event (v14) simulates what a person does. user.click moves the pointer, presses and releases, and sets focus. user.type focuses the field and sends a key event for each character. Prefer it. Two rules for v14:

  • Call userEvent.setup() before render (as in the examples) and use the returned user.
  • Every user.* call returns a promise, so always await it.

The components to test

A sign-up form that validates, calls an API, and shows the server's error:

JavaScript
// src/SignupForm.jsximport { useState } from 'react';export function SignupForm({ onDone }) {  const [errors, setErrors] = useState({});  const [serverError, setServerError] = useState('');  const [busy, setBusy] = useState(false);  async function handleSubmit(e) {    e.preventDefault();    const form = new FormData(e.currentTarget);    const email = String(form.get('email'));    const password = String(form.get('password'));    const next = {};    if (!email.includes('@')) next.email = 'Enter a valid email address';    if (password.length < 8) next.password = 'Password must be at least 8 characters';    setErrors(next);    setServerError('');    if (Object.keys(next).length) return;    setBusy(true);    try {      const res = await fetch('http://localhost/api/signup', {        method: 'POST',        headers: { 'content-type': 'application/json' },        body: JSON.stringify({ email, password }),      });      if (!res.ok) {        const body = await res.json();        setServerError(body.message ?? 'Something went wrong');        return;      }      onDone?.(await res.json());    } finally {      setBusy(false);    }  }  return (    <form onSubmit={handleSubmit} noValidate>      <h2>Create account</h2>      <label htmlFor="email">Email</label>      <input id="email" name="email" type="email" aria-invalid={Boolean(errors.email)} />      {errors.email && <p role="alert">{errors.email}</p>}      <label htmlFor="password">Password</label>      <input id="password" name="password" type="password" aria-invalid={Boolean(errors.password)} />      {errors.password && <p role="alert">{errors.password}</p>}      {serverError && <p role="alert">{serverError}</p>}      <button type="submit" disabled={busy}>{busy ? 'Creating...' : 'Sign up'}</button>    </form>  );}

And a list that loads its data when it appears:

JavaScript
// src/UserList.jsximport { useEffect, useState } from 'react';export function UserList() {  const [state, setState] = useState({ status: 'loading' });  useEffect(() => {    let cancelled = false;    fetch('http://localhost/api/users')      .then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))      .then((users) => !cancelled && setState({ status: 'ok', users }))      .catch((e) => !cancelled && setState({ status: 'error', message: e.message }));    return () => { cancelled = true; };  }, []);  if (state.status === 'loading') return <p>Loading users...</p>;  if (state.status === 'error') return <p role="alert">Could not load users ({state.message})</p>;  if (state.users.length === 0) return <p>No users yet.</p>;  return (    <ul aria-label="Users">      {state.users.map((u) => <li key={u.id}>{u.name}</li>)}    </ul>  );}

The URLs are absolute (http://localhost/...) because Node's fetch cannot resolve a relative URL like /api/users outside a browser page.

Mocking the network with MSW

Instead of replacing fetch (a mock tied to how the code fetches), MSW answers the request. Your component code is unchanged, and a change from fetch to another HTTP client would not break the tests. This follows Lesson 1: double the edge (the network), not your own code.

Create a server with no default handlers, and wire it to the test life cycle:

JavaScript
// src/server.jsimport { setupServer } from 'msw/node';export const server = setupServer();   // no default handlers; each test adds what it needs
JavaScript
// src/setup.js (final)import '@testing-library/jest-dom/vitest';import { afterEach, beforeAll, afterAll } from 'vitest';import { cleanup } from '@testing-library/react';import { server } from './server.js';beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));afterEach(() => { cleanup(); server.resetHandlers(); });   // handlers must not leak between testsafterAll(() => server.close());

onUnhandledRequest: 'error' makes a request without a handler fail loudly. Here is what it prints (I made a request with no handler):

TEXT
[MSW] Error: intercepted a request without a matching request handler:  • GET http://localhost/api/users

An unhandled request can hide behind your error UI. I ran UserList with no handler. The request failed, the component caught it, showed its error message, and a test that only looked for Could not load users passed. The [MSW] message went to the log, not to the assertion. If you test error states, give every happy-path test its own handler, and watch the log for [MSW] Error.

Async UI: findBy and waitFor

After a network call finishes, the DOM changes later. Do not use getBy (it throws now) and do not use setTimeout to wait. Use one of:

  • await screen.findByRole(...): wait until the element appears (a findBy is getBy plus waitFor).
  • await waitFor(() => expect(...)): retry an assertion until it passes or times out. Use it when you wait for something that is not an element.
JavaScript
// src/UserList.test.jsximport { describe, it, expect } from 'vitest';import { render, screen, waitFor } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from './server.js';import { UserList } from './UserList.jsx';describe('UserList', () => {  it('shows loading, then the users', async () => {    server.use(http.get('http://localhost/api/users', () =>      HttpResponse.json([{ id: 1, name: 'Asha' }, { id: 2, name: 'Ben' }])));    render(<UserList />);    expect(screen.getByText('Loading users...')).toBeInTheDocument();   // sync: it is there now    const list = await screen.findByRole('list', { name: 'Users' });   // async: wait for it    expect(list).toHaveTextContent('Asha');    expect(screen.getAllByRole('listitem')).toHaveLength(2);    expect(screen.queryByText('Loading users...')).not.toBeInTheDocument();   // queryBy: "absent" check  });  it('shows an error when the server fails', async () => {    server.use(http.get('http://localhost/api/users', () => new HttpResponse(null, { status: 500 })));    render(<UserList />);    expect(await screen.findByRole('alert')).toHaveTextContent('Could not load users (HTTP 500)');  });});

waitFor works the same way for any assertion, for example await waitFor(() => expect(screen.getByText('Asha')).toBeInTheDocument()) passed in my run.

Notice the three states every async component has: loading, success (and its empty variant) and error. Test all of them. The error and empty cases are the ones that break in production because nobody clicked through them.

Testing forms and error states

JavaScript
// src/SignupForm.test.jsximport { describe, it, expect, vi } from 'vitest';import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { http, HttpResponse } from 'msw';import { server } from './server.js';import { SignupForm } from './SignupForm.jsx';describe('SignupForm', () => {  it('shows validation errors and does not call the server', async () => {    let called = false;    server.use(http.post('http://localhost/api/signup', () => { called = true; return HttpResponse.json({}); }));    const user = userEvent.setup();    render(<SignupForm />);    await user.type(screen.getByLabelText('Email'), 'not-an-email');    await user.type(screen.getByLabelText('Password'), 'short');    await user.click(screen.getByRole('button', { name: 'Sign up' }));    const alerts = screen.getAllByRole('alert');    expect(alerts.map((a) => a.textContent)).toEqual([      'Enter a valid email address',      'Password must be at least 8 characters',    ]);    expect(screen.getByLabelText('Email')).toBeInvalid();    expect(called).toBe(false);  });  it('sends the values and calls onDone', async () => {    let body;    server.use(http.post('http://localhost/api/signup', async ({ request }) => {      body = await request.json();      return HttpResponse.json({ id: 7 }, { status: 201 });    }));    const onDone = vi.fn();    const user = userEvent.setup();    render(<SignupForm onDone={onDone} />);    await user.type(screen.getByLabelText('Email'), 'asha@example.com');    await user.type(screen.getByLabelText('Password'), 'correct horse');    await user.click(screen.getByRole('button', { name: 'Sign up' }));    await vi.waitFor(() => expect(onDone).toHaveBeenCalledWith({ id: 7 }));    expect(body).toEqual({ email: 'asha@example.com', password: 'correct horse' });  });  it('shows the server message when the email is taken', async () => {    server.use(http.post('http://localhost/api/signup', () =>      HttpResponse.json({ message: 'Email already used' }, { status: 409 })));    const user = userEvent.setup();    render(<SignupForm />);    await user.type(screen.getByLabelText('Email'), 'asha@example.com');    await user.type(screen.getByLabelText('Password'), 'correct horse');    await user.click(screen.getByRole('button', { name: 'Sign up' }));    expect(await screen.findByRole('alert')).toHaveTextContent('Email already used');    expect(screen.getByRole('button', { name: 'Sign up' })).toBeEnabled();  });});

What each test shows:

  • Validation: the user sees errors, and the server is never called. The called flag is the one place we check a side effect, because "no request was sent" is the behaviour. toBeInvalid() checks aria-invalid, which is what assistive technology reads.
  • Success: the test checks what went over the wire (the request body) and that the parent got the result through onDone. onDone is part of the component's public contract (a prop), so a mock is fine here.
  • Server error: the user sees the server's message, and the button is usable again, so they can retry.

Full run of all three test files (npx vitest run --reporter=verbose): Test Files 3 passed (3), Tests 9 passed (9), Duration 2.12s. The Counter tests took 45 and 228 ms, the UserList tests 9 to 210 ms, the SignupForm tests 135 to 343 ms (typing character by character is the slow part).

What not to test

  • Implementation details: component state, hook values, the name of a child component, which function was called inside. Test what the user sees.
  • Snapshots of big trees. A large snapshot changes on every edit, people approve the change without reading, and it checks nothing in particular. Use small, targeted assertions. (A small snapshot of a tiny, stable output can be fine.)
  • Styling and layout. jsdom has no layout engine, so it cannot check what is visible on screen the way a browser can. Be careful with toBeVisible(): it checks style rules like display: none, not whether the element is on screen.
  • Things only a real browser can do: drag and drop with real layout, focus rings, real scrolling, CSS. Use a few end-to-end tests (Lesson 4).

Try it

  1. Query choice. You have <button aria-label="Close dialog">X</button>. Which query finds it, and which one should you not use? Answer: getByRole('button', { name: 'Close dialog' }). Avoid getByText('X'), which depends on the symbol, and a test id.
  2. absent or not yet? You want to check that an error message is not shown. Which family? And which one if the message should appear soon? Answer: queryBy... (null if absent) for "not shown". findBy... for "appears soon".
  3. Spot the problem. A test does fireEvent.click(button); expect(screen.getByText('Saved')).toBeInTheDocument(), but the text appears only after a network call. What is wrong? Answer: getByText runs at once and throws. Use await screen.findByText('Saved'), and answer the network call with an MSW handler.

Interview questions

What is the Testing Library philosophy? (Must know)

Test the component the way a user uses it. Its README says the more your tests resemble how the software is used, the more confidence they give. So I find elements by role and label, act with user-event, and assert on what is visible. I do not look at state, props of children or class names. A side benefit: when I refactor the inside, the tests stay green.

Which query do you reach for first, and why? (Must know)

getByRole with a name, then getByLabelText for form fields, then text, and a test id last. Role and label are how real users and screen readers find things, so a successful query also tells me the element is accessible. I use getByTestId only when nothing user-visible identifies the element.

Follow-up: "What if getByRole cannot find it?" I read the error. It lists the accessible roles and names. Often the component has a real problem, such as a button with no accessible name, and I fix the component, not the query.

What is the difference between getBy, queryBy and findBy? (Must know)

getBy throws if the element is missing, so it is for things that must be there now. queryBy returns null, so it is the one to use for "this should not be on the screen". findBy waits (it retries) and then throws, so it is for things that appear after async work. All versions return arrays.

How do you test a component that fetches data? (Must know)

I do not mock fetch itself. I use MSW to answer the request at the network layer, so the component code is the real one. I give each test its own handler with server.use, reset handlers after each test, and test three states: loading, success (and empty) and error. I use await screen.findBy... to wait for the result.

fireEvent or user-event? (Senior signal)

user-event in nearly every case. fireEvent sends one synthetic event. user-event simulates what a person does: typing sends a sequence of key events, a click moves focus. So it catches bugs that depend on that sequence. In v14 I call userEvent.setup() first and await each action.

What is the difference between waitFor and findBy? When do you use waitFor? (Senior signal)

findBy is getBy wrapped in a wait, for an element that will appear. waitFor retries any callback until it stops throwing, so I use it for non-element conditions, such as "this callback was called with that value". I keep the callback to assertions only. I never put an action (like a click) inside waitFor, because it may run many times.

Why did my second test fail with "found multiple elements"? (Trap)

Because the first test's render was not cleaned up. Testing Library only registers its automatic cleanup when the test framework has global afterEach. Vitest's globals option is off by default, so I add afterEach(() => cleanup()) to the setup file. I hit exactly this when I first ran the Counter tests for this lesson.

Is jsdom a real browser? What can't it test? (Senior signal)

No. jsdom and happy-dom imitate the DOM in JavaScript, so they run fast in Node, but they do not do layout, painting or real focus and scroll behaviour. I use them for logic and behaviour of components. For layout, CSS and real browser behaviour I use a few end-to-end tests in a real browser.

How do you test a form well? (Must know)

I fill it in as a user would with getByLabelText and user.type, click the submit button by role and name, and check four things: validation messages (role="alert") and aria-invalid, that no request is sent when the input is invalid, the exact request body for valid input (through the MSW handler), and the failure path where the server answers with an error and the user can retry.

Key takeaways

  • Test components as a user meets them: render, find by role and label, act with user-event, assert on what is visible.
  • Query priority: getByRole (with name), getByLabelText, text, then test id last. A failing role query often points to a real accessibility problem.
  • getBy = must exist now, queryBy = check absence, findBy = wait for it to appear. Use waitFor for non-element conditions.
  • Mock the network with MSW, not fetch. One handler per test, reset after each test, test loading, success, empty and error.
  • With Vitest, add afterEach(() => cleanup()) yourself, or tests leak into each other.
  • Do not test implementation details, big snapshots, or CSS layout in jsdom.
  • Versions used: vitest 5.0.3, jsdom 29.1.1, react 19.3.0, @testing-library/react 16.3.3, user-event 14.6.7, msw 2.15.0.

What's next

Next: Integration and API Tests, Lesson 3. Practise first: take one form in your app and write the three tests above (validation, success, server error), each with an MSW handler.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series