Testing with React Testing Library + Vitest

Tests are how you ship changes without holding your breath. Vitest is a fast, Vite-native test runner with a Jest-compatible API; React Testing Library (RTL) renders your components and lets you query and interact with them the way a real user would. Together they let you assert "when the user clicks Add, a new item appears" — behavior, not implementation. In this lesson you'll learn the render → query → interact → assert loop, why you query by accessible role, and how to test async data.

Learn Testing with React Testing Library + Vitest in our free React course — a beginner-friendly interactive lesson with runnable examples, a practice…

Part of the free React course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

1️⃣ The Shape of a Test

Every component test follows the same four beats: render the component, query for an element, interact with it, then assert on the result. Vitest gives you describe / it / expect ; RTL gives you render and screen .

The demo below models the heart of it: a pure function under test, and a tiny expect().toBe() so you can watch assertions pass:

2️⃣ Queries: Find Elements Like a User

RTL exposes a priority order for queries. Prefer ones a real person (or screen reader) would use. Reach down the list only when nothing higher fits.

3️⃣ Your Turn: Assert a Formatter

Components are full of small pure helpers — formatters, validators, reducers — and those are the easiest, highest-value things to unit test. Fill in the expected values so all three assertions pass.

4️⃣ Testing Async Data

Most real components load data. The pattern: mock the network , render, assert the loading state, then await a findBy for the resolved content. Mocking keeps tests fast and free of flaky real requests.

The demo models how findBy polls until the data appears — "before" is empty, "after" has the loaded name:

These lines of a click test are scrambled. Put them in the correct order:

1) You want to assert an element is not on screen. Which query?

queryBy* — it returns null instead of throwing, so expect(...).not.toBeInTheDocument() works. getBy* would throw before you could assert.

2) Why await user.click(...) instead of fireEvent.click(...) ?

user-event simulates the full sequence a real user triggers (pointer, focus, key events) and awaits React's state flush, avoiding act() warnings and flakiness.

3) A test asserts data after a fetch. getByText or findByText ?

findByText — it returns a promise and retries until the async data renders. getByText would fail because the element isn't there on the first synchronous pass.

📋 Quick Reference

Write assertions for an isValidEmail helper a sign-up form relies on. Add a passing and a failing case using the model expect .

Practice quiz

What jobs do Vitest and React Testing Library each handle?

  • Both are test runners
  • RTL runs the tests; Vitest renders the components
  • Vitest is the test runner (describe/it/expect); RTL renders components and provides queries and user-event
  • They are two names for the same tool

Answer: Vitest is the test runner (describe/it/expect); RTL renders components and provides queries and user-event. Vitest finds and runs tests and gives you assertions. RTL renders components into jsdom and lets you query and interact with them.

Every component test follows which four beats?

  • Render, query, interact, assert
  • Import, mount, snapshot, compare
  • Setup, run, teardown, report
  • Mock, spy, stub, verify

Answer: Render, query, interact, assert. You render the component, query for an element, interact with it, then assert on the result.

Why query by accessible role and text rather than by CSS class or test id?

  • It runs faster
  • Class selectors aren't supported
  • Test ids are deprecated
  • It tests the way a user experiences the app, keeping tests resilient and enforcing accessibility

Answer: It tests the way a user experiences the app, keeping tests resilient and enforcing accessibility. RTL's guiding principle is to find elements the way a user (or screen reader) would. data-testid is a last resort when nothing user-facing identifies an element.

Which query throws immediately if the element is missing?

  • queryBy*
  • getBy*
  • findBy*
  • waitFor

Answer: getBy*. getBy* is synchronous and throws when the element isn't found — use it to assert something already on screen exists.

You want to assert an element is NOT on screen. Which query?

  • queryBy* — it returns null instead of throwing
  • getBy*
  • findBy*
  • renderBy*

Answer: queryBy* — it returns null instead of throwing. queryBy* returns null when the element is absent, so expect(...).not.toBeInTheDocument() works. getBy* would throw before you could assert.

When should you use findBy* instead of getBy*?

  • Always, it's the modern default
  • Only for buttons
  • For elements that appear after an async action, since findBy returns a promise and retries until they show up
  • Only inside beforeEach

Answer: For elements that appear after an async action, since findBy returns a promise and retries until they show up. findBy* returns a promise and retries until the element appears or times out — perfect for data that renders after loading.

Why await user.click(...) with the modern user-event API instead of fireEvent.click(...)?

  • fireEvent doesn't exist anymore
  • user-event simulates the full real interaction sequence and awaits React's state flush, avoiding act() warnings and flakiness
  • It's purely a style preference
  • fireEvent is faster but less accurate only in production

Answer: user-event simulates the full real interaction sequence and awaits React's state flush, avoiding act() warnings and flakiness. userEvent.setup() methods are async to mimic real timing. Awaiting them ensures React has flushed updates before your assertions run.

How do you keep a test of a data-fetching component fast and deterministic?

  • Hit the real API and add retries
  • Increase the test timeout
  • Disable the component's effects
  • Mock the network (e.g. vi.fn() for global.fetch, or MSW), then findBy the resolved content

Answer: Mock the network (e.g. vi.fn() for global.fetch, or MSW), then findBy the resolved content. Stub fetch with vi.fn() or intercept with MSW so requests are controlled, then assert the loading state and await findBy for the data.

A test fails with 'An update was not wrapped in act(...)'. What's the usual cause?

  • You forgot to import React
  • You forgot to await a user-event call or a findBy
  • The component has no state
  • You used getByRole

Answer: You forgot to await a user-event call or a findBy. Unawaited async interactions cause act warnings. Await every async user-event and findBy so React finishes its updates first.

Which RTL query is the highest priority / most preferred?

  • getByTestId
  • getByText
  • getByRole
  • getByDisplayValue

Answer: getByRole. getByRole sits at the top of RTL's priority order — it's how users and assistive tech find buttons, headings, inputs, and links.