Testing with React Testing Library + Vitest

Reviewed & published by Brayan K

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.

Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

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.

// Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";

describe("Counter", () => {
  it("starts at 0 and increments on click", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    // query by accessible role + name — exactly how a user finds it
    expect(screen.getByText("Count: 0")).toBeInTheDocument();

    await user.click(screen.getByRole("button", { name: /increment/i }));

    expect(screen.getByText("Count: 1")).toBeInTheDocument();
  });
});

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

// A test is just: render → query → interact → assert. We can't run a real
// renderer here, so we model a tiny "component" as a function over state,
// and write assertions the way Vitest's expect(...) works.

// The unit under test: a pure function powering a counter component.
function counter(count, action) {
  if (action === "inc") return count + 1;
  if (action === "dec") return count - 1;
  if (action === "reset") return 0;
  return count;
}

// A micro expect(), modeling Vitest's toBe matcher.
function expect(actual) {
  return {
    toBe(expected) {
      const pass = Object.is(actual, expected);
      console.log((pass ? "✅ PASS" : "❌ FAIL") +
        "  expected " + expected + ", got " + actual);
    },
  };
}

// describe("counter") ... it("increments") ...
expect(counter(0, "inc")).toBe(1);     // ✅ PASS  expected 1, got 1
expect(counter(5, "dec")).toBe(4);     // ✅ PASS  expected 4, got 4
expect(counter(9, "reset")).toBe(0);   // ✅ PASS  expected 0, got 0
expect(counter(3, "noop")).toBe(3);    // ✅ PASS  expected 3, got 3

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.

PriorityQueryUse for
1 (best)getByRolebuttons, headings, inputs, links
2getByLabelTextform fields with a label
3getByTextnon-interactive text content
last resortgetByTestIdnothing user-facing identifies it
// A search box test — query by label, type, then assert
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

it("filters the list as you type", async () => {
  const user = userEvent.setup();
  render(<SearchList items={["apple", "banana", "cherry"]} />);

  const input = screen.getByLabelText(/search/i);   // by its label
  await user.type(input, "an");

  expect(screen.getByText("banana")).toBeInTheDocument();
  expect(screen.queryByText("cherry")).not.toBeInTheDocument(); // queryBy = may be absent
});

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.

// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// You're testing a 'formatPrice' helper a component uses to render labels.

function formatPrice(cents) {
  return "$" + (cents / 100).toFixed(2);
}

function expect(actual) {
  return {
    toBe(expected) {
      console.log(Object.is(actual, expected) ? "✅ PASS" : "❌ FAIL", "->", actual);
    },
  };
}

// 1) 1299 cents should format as "$12.99"
expect(formatPrice(1299)).toBe(___);   // 👉 "$12.99"
// 2) 0 cents should format as "$0.00"
expect(formatPrice(0)).toBe(___);      // 👉 "$0.00"
// 3) 500 cents should format as "$5.00"
expect(formatPrice(500)).toBe(___);    // 👉 "$5.00"

// ✅ Expected output:
//    ✅ PASS -> $12.99
//    ✅ PASS -> $0.00
//    ✅ PASS -> $5.00

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.

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import UserCard from "./UserCard";

beforeEach(() => {
  // Stub the global fetch with a controlled response.
  global.fetch = vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ name: "Ada Lovelace", followers: 42 }),
  });
});

it("shows loading then the user", async () => {
  render(<UserCard username="ada" />);

  // synchronous: the loading state is there immediately
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // async: findBy retries until the resolved data renders
  expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
  expect(screen.getByText("42 followers")).toBeInTheDocument();

  expect(fetch).toHaveBeenCalledWith("https://api.github.com/users/ada");
});

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

// findBy() retries until an element appears — perfect for data that loads
// asynchronously. We model the retry loop so you can see the idea.

// A fake async data source (resolves after a tick).
function loadUser() {
  return new Promise((resolve) =>
    setTimeout(() => resolve({ name: "Ada Lovelace" }), 10)
  );
}

// model of screen.findByText: poll a getter until it returns a value.
async function findValue(getter, { timeout = 1000, interval = 5 } = {}) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    const value = getter();
    if (value != null) return value;
    await new Promise((r) => setTimeout(r, interval));
  }
  throw new Error("Timed out: element never appeared");
}

(async () => {
  let rendered = null;                 // models the DOM text node
  loadUser().then((u) => { rendered = u.name; });  // component sets state -> renders

  console.log("before:", rendered);    // before: null  (still loading)
  const name = await findValue(() => rendered);
  console.log("after:", name);         // after: Ada Lovelace  (data arrived)
})();

Reorder Challenge

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

A.   await user.click(screen.getByRole("button", { name: /add/i }));
B.   const user = userEvent.setup();
C.   it("adds a todo on click", async () => {
D.   expect(screen.getByText("Buy milk")).toBeInTheDocument();
E.   render(<TodoApp />);

Quick Recall

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.

Common Errors (and the fix)

📋 Quick Reference

TaskAPI
Renderrender(<Component />)
Find a buttonscreen.getByRole("button", { name: /save/i })
Click / typeawait user.click(el) / await user.type(el, "hi")
Assert presentexpect(el).toBeInTheDocument()
Wait for asyncawait screen.findByText("Done")
Mock a functionconst fn = vi.fn().mockResolvedValue(data)

Mini-Challenge: Test a Validator

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

// The unit under test:
function isValidEmail(s) {
  return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
}

function expect(actual) {
  return { toBe(expected) {
    console.log(Object.is(actual, expected) ? "✅ PASS" : "❌ FAIL", "->", actual);
  }};
}

// your code here:
// 1) expect(isValidEmail("a@b.com")).toBe(true)
// 2) expect(isValidEmail("nope")).toBe(false)

🎉 Lesson Complete

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.

Continue this course

Related lessons