Skip to main content

React Testing Library with Vitest: Full Tutorial

React Testing Library is the gold standard for testing React components because it enforces testing the way your users experience the app—by clicking buttons, filling inputs, and reading text. Combined with Vitest, it's blazingly fast and produces tests that stay maintainable as components evolve (Testing Library community survey, 2026). This article teaches you the queries, patterns, and practices that make React Testing Library tests robust.

What React Testing Library Does (and Doesn't)

React Testing Library queries the DOM like a user would. It doesn't query component props or internal state—it finds elements by their accessible names, roles, and text content. This approach catches real bugs: if your component renders a broken button label that sighted users see, your test will fail too.

Install React Testing Library alongside Vitest:

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

Then add this line to your vitest.setup.ts to enable matchers like toBeInTheDocument():

import '@testing-library/jest-dom/vitest';

Query Hierarchy: What to Use First

React Testing Library recommends queries in order of preference. Higher-priority queries are more resilient to implementation changes.

Query PriorityMethodExampleBest For
1. RecommendedgetByRole()getByRole('button', { name: /submit/i })Buttons, headings, inputs, checkboxes
2. RecommendedgetByLabelText()getByLabelText('Email')Form fields with labels
3. AcceptablegetByPlaceholderText()getByPlaceholderText('Enter name')Text inputs with placeholder (avoid—inaccessible)
4. AcceptablegetByText()getByText(/welcome/i)Text blocks, divs without roles
5. Last ResortgetByTestId()getByTestId('modal-overlay')Dynamic content, no accessible name

Why this order? getByRole() enforces accessible HTML. If your component doesn't have a proper <label>, <button>, or ARIA role, getByRole() fails—which is good because it means your component is inaccessible to real users (and screen readers).

Query Variants: Single vs. Multiple vs. Async

Every query has three variants:

// getBy* → throws if not found (instant fail)
const button = screen.getByRole('button', { name: /submit/i });

// queryBy* → returns null if not found (good for assertions like "should not exist")
const deletedItem = screen.queryByText('Old Item');
expect(deletedItem).not.toBeInTheDocument();

// findBy* → async/await, returns a Promise (good for elements that appear after state updates or network calls)
const loadedUser = await screen.findByText('John Doe');
expect(loadedUser).toBeInTheDocument();

Complete Example: Testing a Form Component

Let's test a realistic form component. First, the component (LoginForm.tsx):

import { useState } from 'react';

export function LoginForm({ onSubmit }: { onSubmit: (email: string, password: string) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('Email and password required');
return;
}
setError('');
onSubmit(email, password);
};

return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>

<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>

{error && <p role="alert">{error}</p>}

<button type="submit">Sign In</button>
</form>
);
}

Now the test (LoginForm.test.tsx):

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
it('submits form with email and password', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);

// Query by label (recommended for inputs)
const emailInput = screen.getByLabelText('Email');
const passwordInput = screen.getByLabelText('Password');

// Simulate user typing
await userEvent.type(emailInput, '[email protected]');
await userEvent.type(passwordInput, 'secret123');

// Click submit button (query by role + name)
const submitBtn = screen.getByRole('button', { name: /sign in/i });
await userEvent.click(submitBtn);

// Assert callback was called with correct args
expect(handleSubmit).toHaveBeenCalledWith('[email protected]', 'secret123');
});

it('shows validation error when fields are empty', async () => {
render(<LoginForm onSubmit={vi.fn()} />);

const submitBtn = screen.getByRole('button', { name: /sign in/i });
await userEvent.click(submitBtn);

// Query by role='alert' for error messages
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Email and password required');
});
});

Key pattern: Label each input properly with <label htmlFor="id">, wrap errors with role="alert", and use screen.getByLabelText() to query inputs. This forces your component to be accessible and makes tests readable.

userEvent vs. fireEvent: Always Use userEvent

Never use fireEvent for user interactions. fireEvent directly invokes event handlers and doesn't simulate browser behavior—it misses bugs your real users will encounter.

// ❌ Bad: doesn't simulate user behavior
fireEvent.click(button);

// ✓ Good: simulates real mouse/keyboard input
await userEvent.click(button);

userEvent is async and waits for state updates. Always await it.

Async Queries and waitFor

When testing components that update after a network call or delayed state change, use findBy* or waitFor:

it('loads and displays user data', async () => {
render(<UserProfile userId="123" />);

// Wait for the name to appear (data loads asynchronously)
const name = await screen.findByText('Alice Johnson');
expect(name).toBeInTheDocument();
});

Or for more control:

it('shows loading spinner then data', async () => {
render(<UserProfile userId="123" />);

// Initial state: spinner visible
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading');

// After data arrives: spinner gone, name shown
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
});
});

Key Takeaways

  • React Testing Library queries the DOM as users see it: by role, label, and text—not by component props.
  • Use queries in priority order: getByRole() > getByLabelText() > getByText() > getByTestId().
  • Always use userEvent (not fireEvent) for user interactions; always await it.
  • Use queryBy* to assert elements don't exist; use findBy* for async elements that appear after state updates.
  • Write accessible HTML—proper <label> and ARIA roles—and your tests will be both readable and maintainable.

Frequently Asked Questions

What's the difference between getByRole and getByLabelText?

getByRole() finds elements by their semantic HTML role (button, heading, textbox, etc.). getByLabelText() finds form inputs associated with a <label>. Use getByRole() for buttons, headings, and any element with a semantic role. Use getByLabelText() specifically for inputs, selects, and textareas that have a label.

Why is getByTestId considered last resort?

getByTestId() breaks the "user-centric" philosophy because users don't see test IDs. If you rely on getByTestId(), your test won't catch bugs where the UI is inaccessible or unlabeled. Use it only for dynamic content (like list items generated from an array) or when no semantic query works. Always ask: "Can a user find this element?"

How do I test a component that uses portals or iframes?

For portals, React Testing Library queries the entire DOM by default, so if your portal renders to document.body, it's automatically queryable. For iframes, you need to query within the iframe's document: use getByTestId() or within() to scope queries to the iframe's content.

How do I test a button that triggers a modal after a delay?

Use findBy* to wait for the modal to appear. findBy* waits up to 1000ms by default (configurable):

const modal = await screen.findByRole('dialog', { timeout: 3000 });

Should I use aria-label or test IDs for debugging?

Always prefer semantic HTML and labels. Use aria-label only when a visible label is impossible (e.g., an icon-only button). aria-label="Close menu" on an <img> icon makes the icon accessible to screen readers. Test IDs are last resort—if you're adding data-testid to something without a semantic role or label, that's a sign the HTML is inaccessible.

Further Reading