Skip to main content

βš›οΈ Lesson 9.2: React Testing Library

Master React Testing Library and learn to test your components the way users interact with them. Discover the Testing Library philosophy, query methods, user interaction testing, and best practices for component testing.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Understand the Testing Library philosophy and why it matters
  • Set up React Testing Library in a TypeScript project
  • Render React components in tests and query elements effectively
  • Distinguish between getBy, findBy, and queryBy query methods
  • Simulate user interactions with userEvent
  • Test asynchronous behavior in React components
  • Write type-safe tests for React components with TypeScript

Estimated Time: 60-75 minutes

Project: Test a complete React component with user interactions

In This Lesson

πŸ“– Introduction to React Testing Library

In the previous lesson, we learned to test utility functions with plain assertions. A function is easy to reason about: give it an input, check the output. But React applications aren't just functions β€” they're interactive user interfaces built from components that render markup, hold state, and respond to clicks and keystrokes. How do you test something visual and interactive without a real browser and a human clicking through it?

Think of it like inspecting a vending machine. πŸ₯€ One way is to unscrew the back panel and verify each internal relay is in the right position β€” fiddly, and the moment someone rewires the machine your checklist is worthless. The better way is to stand in front of it like a customer: press the button for a soda and confirm a soda drops out. You don't care how it's wired, only that it behaves correctly. That is exactly the mindset React Testing Library asks you to adopt.

Enter React Testing Library (RTL) β€” the most popular and recommended way to test React components, and the approach the React team itself points you to. It's built on a simple but powerful philosophy: test your components the way users use them. Instead of poking at internal state, you find elements the way a person (or a screen reader) would, interact with them the way a person would, and assert on what a person would actually see.

πŸ“– What is React Testing Library?

React Testing Library is a lightweight testing library that provides utilities for testing React components by interacting with them as a user would. It encourages testing behavior over implementation details.

Why React Testing Library?

Before RTL, React developers often used Enzyme, which exposed component internals like state and props. While powerful, this led to brittle tests that broke whenever you refactored. RTL took a different approach:

graph LR A["Old Approach (Enzyme)"] --> B[Test Internal State] A --> C[Test Props Directly] A --> D[Test Instance Methods] E[RTL Approach] --> F[Test What User Sees] E --> G[Test User Interactions] E --> H[Test UI Changes] style A fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff style E fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

βœ… The RTL Advantage

React Testing Library helps you write tests that:

  • Stay stable during refactoring: Tests don't break when you change implementation
  • Catch real bugs: Focus on user-facing functionality
  • Improve accessibility: Encourages finding elements by accessible attributes
  • Build confidence: If tests pass, the UI actually works for users

What Can You Test with RTL?

  • Rendering: Does the component display correctly?
  • User interactions: What happens when users click, type, or navigate?
  • Props: Does the component respond correctly to different props?
  • Conditional rendering: Does the right content show in different scenarios?
  • Async operations: Do loading states and data fetching work?
  • Accessibility: Can the component be used with assistive technologies?
πŸ’¬ From the Creator: "The more your tests resemble the way your software is used, the more confidence they can give you." – Kent C. Dodds, creator of React Testing Library

🎭 The Testing Library Philosophy

Understanding the philosophy behind React Testing Library is crucial to writing effective tests. Let's explore the core principles that guide how we test components.

Principle 1: Test Behavior, Not Implementation

Users don't care about your component's state or prop names. They care about what they see and how it behaves.

❌ Bad: Testing Implementation

// DON'T DO THIS (old Enzyme-style thinking)
test('counter increments state', () => {
  const { container } = render(<Counter />);
  const component = container.querySelector('.counter');

  // Reaching into internal state and instance methods - BAD!
  // This breaks the instant you refactor, and RTL won't even give
  // you .state or .instance() β€” those belong to a bygone era.
  expect(component.state.count).toBe(0);
  component.instance().increment();
  expect(component.state.count).toBe(1);
});

βœ… Good: Testing Behavior

// DO THIS INSTEAD
test('counter displays incremented value when button is clicked', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  // Test what the user sees
  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  // Test the interaction the way a real user performs it
  await user.click(screen.getByRole('button', { name: /increment/i }));

  // Verify the visible result
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

Principle 2: Query By Accessibility

RTL encourages finding elements the way assistive technologies do. This makes your tests and your components more accessible.

Priority Query Method When to Use Example
1st (Best) getByRole Buttons, links, form fields with accessible roles getByRole('button', { name: 'Submit' })
2nd getByLabelText Form inputs with labels getByLabelText('Email')
3rd getByPlaceholderText Inputs with placeholder text getByPlaceholderText('Enter email...')
4th getByText Non-interactive text content getByText('Welcome!')
5th getByDisplayValue Form inputs with current values getByDisplayValue('john@example.com')
6th (Last Resort) getByTestId When no other query works getByTestId('custom-component')

⚠️ Why Query Priority Matters

Using getByRole first ensures your components are accessible. If you can't find an element by role, that is often a signal the element isn't reachable by screen reader users either β€” so a failing query is doing double duty, flagging both a test problem and a real accessibility gap. This makes your tests a tool for improving accessibility!

When you're staring at an element and wondering which query to reach for, walk down this ladder β€” stop at the first rung that fits:

Query Priority Ladder Ordered from most preferred to last resort: getByRole, getByLabelText, getByPlaceholderText, getByText, then getByTestId as a last resort. 1. getByRole buttons, links, headings, inputs (accessible) 2. getByLabelText form fields tied to a <label> 3. getByPlaceholderText / getByText 4. getByDisplayValue / getByAltText / getByTitle 5. getByTestId β€” last resort only most like a real user furthest from the user
Figure: The query priority ladder. Prefer queries near the top β€” they mirror how real users and assistive tech find things. Reach for getByTestId only when nothing else works.

Principle 3: No Implementation Details

RTL intentionally doesn't give you access to component internals. You can't access:

  • Component state
  • Component instance methods
  • Props (except through their effects on the UI)
  • Lifecycle methods

This might feel limiting at first, but it's actually liberating! Your tests become resilient to refactoring.

πŸ’‘ Real-World Example

Imagine you have a LoginForm component. You refactor it from:

  • Class component β†’ Functional component
  • Local state β†’ useReducer
  • One big component β†’ Multiple smaller components

With RTL, your tests don't need to change at all! As long as users can still log in, the tests pass. This is the power of testing behavior.

Principle 4: Prefer User-Facing Queries

When multiple query options exist, choose the one closest to how users experience your app:


// ❌ Less ideal: Using test IDs
<button data-testid="submit-btn">Submit</button>
getByTestId('submit-btn')

// βœ… Better: Using accessible role
<button>Submit</button>
getByRole('button', { name: 'Submit' })

// βœ… Even better: Using semantic HTML and labels
<button aria-label="Submit registration form">Submit</button>
getByRole('button', { name: /submit registration/i })
                

The Mindset Shift

Testing with RTL requires a mindset shift:

graph TD A[Developer Thinking] --> B["How did I implement this?"] A --> C["What's the state value?"] A --> D["What props did I pass?"] E[User Thinking] --> F["What do I see on screen?"] E --> G["What can I click/type?"] E --> H["What happens when I interact?"] I[RTL Testing] --> E style A fill:#ff6b6b,stroke:#c92a2a,stroke-width:2px,color:#fff style E fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff style I fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff

When writing tests, constantly ask yourself: "How would a user accomplish this task?" Then test exactly that.

βš™οΈ Setting Up React Testing Library

Let's set up React Testing Library in a TypeScript project. If you created your project with Vite, some of this might already be configured!

Installation

Install React Testing Library and its dependencies:

# Core Testing Library packages
npm install --save-dev @testing-library/react
npm install --save-dev @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

# Test runner + a browser-like DOM environment
npm install --save-dev vitest jsdom @vitest/ui

πŸ’‘ Package Breakdown

  • @testing-library/react: the core of RTL β€” render, screen, and the query methods
  • @testing-library/jest-dom: custom matchers for asserting on DOM nodes, like toBeInTheDocument() and toBeDisabled()
  • @testing-library/user-event: realistic user interaction simulation (the modern companion to RTL)
  • vitest: a fast, Vite-native test runner with a Jest-compatible API
  • jsdom: a browser-like DOM implementation so your components can render in Node.js (prefer happy-dom if you want a lighter, faster environment)

Notice there's no @types/jest here. With Vitest you get types from vitest/globals instead β€” mixing in Jest's types can cause clashing matcher definitions. @types/react and @types/react-dom already come in with React in a Vite + TypeScript project, so you rarely install them separately.

Configure Vitest (for Vite Projects)

Create or update vitest.config.ts:


import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
  },
});
                

Setup File

Create src/test/setup.ts to configure testing utilities:


import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});
                

βœ… What Does This Setup Do?

  • @testing-library/jest-dom: Adds helpful matchers like .toBeInTheDocument()
  • cleanup(): Unmounts components after each test to prevent memory leaks
  • jsdom environment: Simulates a browser DOM in Node.js

TypeScript Configuration

Update your tsconfig.json to include test types:


{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"],
    "jsx": "react-jsx",
    // ... other options
  },
  "include": ["src/**/*", "src/**/*.test.tsx"]
}
                

Add Test Scripts

Update package.json with test commands:


{
  "scripts": {
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage"
  }
}
                

File Naming Convention

Place test files next to the components they test:


src/
  components/
    Button/
      Button.tsx
      Button.test.tsx    ← Test file
      Button.css
    Counter/
      Counter.tsx
      Counter.test.tsx   ← Test file
                

Verify Setup

Create a simple test to verify everything works:


// src/App.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

describe('Setup Test', () => {
  it('renders without crashing', () => {
    render(<div>Hello, Testing!</div>);
    expect(screen.getByText('Hello, Testing!')).toBeInTheDocument();
  });
});
                

Run the test:


npm test
                

You should see:


 βœ“ src/App.test.tsx (1)
   βœ“ Setup Test (1)
     βœ“ renders without crashing

Test Files  1 passed (1)
     Tests  1 passed (1)
                

πŸŽ‰ Setup Complete!

If you see the green checkmark, you're ready to start testing React components! The setup ensures that every test has access to React Testing Library utilities and custom matchers.

🎨 Rendering Components

The first step in testing a React component is rendering it. React Testing Library provides the render function to mount components into a simulated DOM. Almost every component test you'll ever write follows the same three-beat rhythm, often called Arrange–Act–Assert: render the component, interact with it or query it, then assert on what a user would see.

graph LR A["render(<Component />)"] --> B["screen.getBy... / findBy..."] B --> C["expect(...).toBeInTheDocument()"] A2["Arrange"] -.-> A B2["Act / Query"] -.-> B C2["Assert"] -.-> C style A fill:#4dabf7,stroke:#1971c2,stroke-width:2px,color:#fff style B fill:#845ef7,stroke:#5f3dc4,stroke-width:2px,color:#fff style C fill:#51cf66,stroke:#2f9e44,stroke-width:2px,color:#fff

Keep that flow in your head and most tests almost write themselves. Let's see it in action.

Basic Rendering

Let's start with a simple component and test it:


// Greeting.tsx
interface GreetingProps {
  name: string;
}

export function Greeting({ name }: GreetingProps) {
  return <h1>Hello, {name}!</h1>;
}
                

// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Greeting } from './Greeting';

describe('Greeting', () => {
  it('displays the greeting with the provided name', () => {
    // Arrange & Act: Render the component
    render(<Greeting name="Alice" />);
    
    // Assert: Check if the text appears
    expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
  });
});
                

πŸ“– Understanding render()

render() mounts a React component into a container attached to document.body, inside the jsdom (or happy-dom) environment your test runner provides. It returns an object with query methods and utilities like rerender and unmount, but we usually reach for the global screen object instead β€” it queries the whole document and keeps tests consistent and readable.

The screen Object

Instead of destructuring queries from render(), we use the screen object:


// ❌ Old style - harder to read
const { getByText, getByRole } = render(<MyComponent />);
expect(getByText('Hello')).toBeInTheDocument();

// βœ… Preferred style - cleaner and more consistent
render(<MyComponent />);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
                

Rendering with Props

Test components with different prop combinations:


// Button.tsx
interface ButtonProps {
  label: string;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  onClick?: () => void;
}

export function Button({ 
  label, 
  variant = 'primary', 
  disabled = false,
  onClick 
}: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  );
}
                

// Button.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';

describe('Button', () => {
  it('renders with label text', () => {
    render(<Button label="Click me" />);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });
  
  it('applies primary variant by default', () => {
    render(<Button label="Submit" />);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('btn-primary');
  });
  
  it('applies secondary variant when specified', () => {
    render(<Button label="Cancel" variant="secondary" />);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('btn-secondary');
  });
  
  it('renders as disabled when disabled prop is true', () => {
    render(<Button label="Disabled" disabled />);
    expect(screen.getByRole('button')).toBeDisabled();
  });
});
                

βœ… Pro Tip: Test Multiple Scenarios

For components with props, test:

  • Default prop values
  • Each variant or state
  • Edge cases (empty strings, null, undefined)
  • Different prop combinations

Rendering with Children

Components that accept children need special consideration:


// Card.tsx
interface CardProps {
  title: string;
  children: React.ReactNode;
}

export function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="card-body">
        {children}
      </div>
    </div>
  );
}
                

// Card.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Card } from './Card';

describe('Card', () => {
  it('renders title and children', () => {
    render(
      <Card title="My Card">
        <p>Card content here</p>
      </Card>
    );
    
    expect(screen.getByRole('heading', { name: 'My Card' })).toBeInTheDocument();
    expect(screen.getByText('Card content here')).toBeInTheDocument();
  });
  
  it('renders multiple children', () => {
    render(
      <Card title="Multiple Items">
        <p>First paragraph</p>
        <p>Second paragraph</p>
        <button>Action</button>
      </Card>
    );
    
    expect(screen.getByText('First paragraph')).toBeInTheDocument();
    expect(screen.getByText('Second paragraph')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
  });
});
                

Rerendering Components

Sometimes you need to test how a component updates when props change:


// Counter.tsx
interface CounterProps {
  initialCount?: number;
}

export function Counter({ initialCount = 0 }: CounterProps) {
  const [count, setCount] = React.useState(initialCount);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}
                

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

describe('Counter', () => {
  it('updates when initialCount prop changes', () => {
    // Initial render
    const { rerender } = render(<Counter initialCount={0} />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
    
    // Rerender with new prop
    rerender(<Counter initialCount={5} />);
    expect(screen.getByText('Count: 5')).toBeInTheDocument();
  });
});
                

πŸ’‘ When to Use rerender()

Use rerender() when testing:

  • How components respond to prop changes
  • Memoization and optimization behavior
  • Effects that depend on props

Note: Most tests won't need rerender()β€”focus on user interactions instead.

Unmounting Components

Test cleanup and unmounting behavior:


describe('Component cleanup', () => {
  it('cleans up resources on unmount', () => {
    const cleanup = vi.fn();
    
    function ComponentWithCleanup() {
      React.useEffect(() => {
        return cleanup; // Cleanup function
      }, []);
      
      return <div>Test</div>;
    }
    
    const { unmount } = render(<ComponentWithCleanup />);
    
    // Cleanup hasn't been called yet
    expect(cleanup).not.toHaveBeenCalled();
    
    // Unmount the component
    unmount();
    
    // Now cleanup should have been called
    expect(cleanup).toHaveBeenCalledTimes(1);
  });
});
                

πŸ” Query Methods

Once a component is rendered, you need to find elements inside it before you can assert or interact. RTL gives you a family of query methods, and the trick to using them well is realizing they split along two axes: what you search by (role, label, text…) and how the query behaves when the element is missing (throw, return null, or wait). Get those two axes straight and query selection stops feeling like guesswork.

Query Types: getBy, queryBy, findBy

Each query comes in three variants, and choosing the right one is really a question of intent β€” think of them as three different ways of asking about an element:

  • getBy… β€” "This must be here right now." Throws a helpful error (printing the DOM) if it's missing. Your everyday default.
  • queryBy… β€” "Tell me if this exists, and don't panic if it doesn't." Returns null instead of throwing, which is exactly what you want when asserting something is absent.
  • findBy… β€” "This will show up soon β€” wait for it." Returns a Promise and retries for a short window, perfect for content that appears after an async operation.

Here's the full grid, including the *AllBy variants for matching multiple elements:

Query Type Returns Throws Error? Async? Use When
getBy... Element Yes (if not found) No Element should be present
queryBy... Element or null No No Element might not exist
findBy... Promise<Element> Yes (if not found) Yes Element appears asynchronously
getAllBy... Element[] Yes (if none found) No Multiple elements present
queryAllBy... Element[] No No Multiple elements might exist
findAllBy... Promise<Element[]> Yes (if none found) Yes Multiple elements appear async

1. getBy Queries (Most Common)

Use getBy when you expect the element to be in the document:


test('getBy examples', () => {
  render(
    <div>
      <h1>Welcome</h1>
      <button>Click me</button>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" />
    </div>
  );
  
  // These all throw if element not found
  expect(screen.getByText('Welcome')).toBeInTheDocument();
  expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
                

βœ… When to Use getBy

Use getBy when:

  • You're asserting the element exists
  • The element should always be present
  • You want an error if the element is missing

This is your default choice for most queries!

2. queryBy Queries (For Absence)

Use queryBy when checking if an element does NOT exist:


test('queryBy examples', () => {
  render(<div><p>Visible content</p></div>);
  
  // This works - element exists
  expect(screen.queryByText('Visible content')).toBeInTheDocument();
  
  // This also works - checking for absence
  expect(screen.queryByText('Hidden content')).not.toBeInTheDocument();
  
  // This would FAIL with getBy (throws error)
  // expect(screen.getByText('Hidden content')).not.toBeInTheDocument();
});
                

// Practical example: Testing conditional rendering
function ConditionalContent({ isLoggedIn }: { isLoggedIn: boolean }) {
  return (
    <div>
      {isLoggedIn ? (
        <button>Logout</button>
      ) : (
        <button>Login</button>
      )}
    </div>
  );
}

test('shows login button when not logged in', () => {
  render(<ConditionalContent isLoggedIn={false} />);
  
  expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
  expect(screen.queryByRole('button', { name: 'Logout' })).not.toBeInTheDocument();
});

test('shows logout button when logged in', () => {
  render(<ConditionalContent isLoggedIn={true} />);
  
  expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument();
  expect(screen.queryByRole('button', { name: 'Login' })).not.toBeInTheDocument();
});
                

πŸ’‘ When to Use queryBy

Use queryBy when:

  • Testing that an element does NOT exist
  • Checking conditional rendering
  • Verifying elements are hidden or removed

3. findBy Queries (For Async)

Use findBy when elements appear asynchronously:


function AsyncComponent() {
  const [data, setData] = React.useState<string | null>(null);
  
  React.useEffect(() => {
    // Simulate async data loading
    setTimeout(() => {
      setData('Loaded data!');
    }, 100);
  }, []);
  
  return (
    <div>
      {data ? <p>{data}</p> : <p>Loading...</p>}
    </div>
  );
}

test('displays data after loading', async () => {
  render(<AsyncComponent />);
  
  // Initially shows loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Wait for data to appear (findBy returns a Promise)
  const dataElement = await screen.findByText('Loaded data!');
  expect(dataElement).toBeInTheDocument();
  
  // Loading message should be gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
                

⚠️ Common Mistake: Using getBy for Async


// ❌ This will fail - getBy doesn't wait
test('wrong approach', () => {
  render(<AsyncComponent />);
  expect(screen.getByText('Loaded data!')).toBeInTheDocument(); // Error!
});

// βœ… Use findBy instead
test('correct approach', async () => {
  render(<AsyncComponent />);
  expect(await screen.findByText('Loaded data!')).toBeInTheDocument();
});
                    

Query Methods Reference

Each query type has multiple methods for finding elements:


// By Role (BEST - most accessible)
screen.getByRole('button', { name: 'Submit' })
screen.getByRole('heading', { level: 1 })
screen.getByRole('textbox', { name: 'Email' })

// By Label Text (great for forms)
screen.getByLabelText('Username')
screen.getByLabelText(/email/i) // Case-insensitive regex

// By Placeholder
screen.getByPlaceholderText('Enter your email...')

// By Text
screen.getByText('Welcome back!')
screen.getByText(/welcome/i) // Case-insensitive

// By Display Value (current input value)
screen.getByDisplayValue('john@example.com')

// By Alt Text (for images)
screen.getByAltText('Profile picture')

// By Title
screen.getByTitle('Close dialog')

// By Test ID (last resort)
screen.getByTestId('custom-element')
                

Query Options

Queries accept options to refine your search:


// Exact match (default: true)
screen.getByText('Submit', { exact: true })
screen.getByText('Sub', { exact: false }) // Matches "Submit"

// Case sensitivity
screen.getByText('hello', { exact: false }) // Matches "Hello"

// Using regex for flexible matching
screen.getByText(/submit/i) // Case-insensitive
screen.getByText(/^Welcome/) // Starts with "Welcome"

// Role with name
screen.getByRole('button', { 
  name: 'Submit Form' // Accessible name
})

// Role with description
screen.getByRole('button', {
  description: 'Submits the registration form'
})

// Multiple options
screen.getByRole('heading', {
  level: 2,
  name: /user profile/i
})
                

Debugging Queries

When a query fails, RTL provides helpful debugging tools:


test('debugging example', () => {
  render(<MyComponent />);
  
  // Print the current DOM
  screen.debug();
  
  // Print a specific element
  const button = screen.getByRole('button');
  screen.debug(button);
  
  // Get suggested queries (very helpful!)
  screen.logTestingPlaygroundURL();
});
                

βœ… Query Selection Guide

  1. Can you use getByRole? Use it! (Most accessible)
  2. Is it a form field with a label? Use getByLabelText
  3. Is it text content? Use getByText
  4. Is it async? Use findBy...
  5. Checking absence? Use queryBy...
  6. Multiple elements? Use getAllBy... or findAllBy...
  7. Last resort? Use getByTestId

πŸ‘† Simulating User Interactions

Testing how components respond to user interactions is where React Testing Library really shines. Rendering proves a component looks right; interaction proves it works. We'll use @testing-library/user-event β€” the modern companion library that simulates interactions the way a real person performs them, dispatching the full sequence of events a browser would fire rather than a single synthetic one.

user-event vs fireEvent

RTL provides two ways to simulate interactions:

Feature fireEvent user-event
Realism Triggers single event Simulates complete user interaction
Example: Click Just triggers 'click' mousedown β†’ focus β†’ mouseup β†’ click
Example: Type Just changes value keydown β†’ keypress β†’ input β†’ keyup (per character)
Async Synchronous Returns promises (async)
Recommendation Avoid (unless debugging) Prefer this (more realistic)

πŸ’‘ Why user-event?

user-event simulates interactions more realistically. For example, clicking a button with user-event triggers hover, focus, mousedown, mouseup, and click eventsβ€”just like a real user. This catches bugs that fireEvent would miss!

Setting Up user-event


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

describe('Component with interactions', () => {
  it('handles user interactions', async () => {
    // Setup user-event
    const user = userEvent.setup();
    
    render(<MyComponent />);
    
    // Use the user object for interactions
    await user.click(screen.getByRole('button'));
  });
});
                

Clicking Elements


function ClickCounter() {
  const [count, setCount] = React.useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  );
}

test('increments count when button is clicked', async () => {
  const user = userEvent.setup();
  render(<ClickCounter />);
  
  // Initial state
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  // Click the button
  await user.click(screen.getByRole('button', { name: 'Increment' }));
  
  // Verify the update
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
  
  // Click again
  await user.click(screen.getByRole('button', { name: 'Increment' }));
  expect(screen.getByText('Count: 2')).toBeInTheDocument();
});
                

Typing in Inputs


function SearchForm() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState<string[]>([]);
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setResults([`Result for: ${query}`]);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="search">Search</label>
      <input
        id="search"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
      
      <ul>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </form>
  );
}

test('searches when user types and submits', async () => {
  const user = userEvent.setup();
  render(<SearchForm />);
  
  // Type in the search input
  const searchInput = screen.getByLabelText('Search');
  await user.type(searchInput, 'React Testing');
  
  // Verify the input value
  expect(searchInput).toHaveValue('React Testing');
  
  // Submit the form
  await user.click(screen.getByRole('button', { name: 'Search' }));
  
  // Verify results appear
  expect(screen.getByText('Result for: React Testing')).toBeInTheDocument();
});
                

Typing Special Keys


test('handles keyboard shortcuts', async () => {
  const user = userEvent.setup();
  render(<TextEditor />);
  
  const textarea = screen.getByRole('textbox');
  
  // Type text
  await user.type(textarea, 'Hello World');
  
  // Select all (Ctrl+A)
  await user.keyboard('{Control>}a{/Control}');
  
  // Delete
  await user.keyboard('{Backspace}');
  
  expect(textarea).toHaveValue('');
});

test('handles Enter key', async () => {
  const user = userEvent.setup();
  const handleSubmit = vi.fn();
  
  render(<ChatInput onSubmit={handleSubmit} />);
  
  await user.type(screen.getByRole('textbox'), 'Hello{Enter}');
  
  expect(handleSubmit).toHaveBeenCalledWith('Hello');
});
                

Selecting Options


function CountrySelector() {
  const [country, setCountry] = React.useState('');
  
  return (
    <div>
      <label htmlFor="country">Country</label>
      <select 
        id="country" 
        value={country}
        onChange={(e) => setCountry(e.target.value)}
      >
        <option value="">Select a country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
        <option value="ca">Canada</option>
      </select>
      
      {country && <p>Selected: {country}</p>}
    </div>
  );
}

test('selects a country from dropdown', async () => {
  const user = userEvent.setup();
  render(<CountrySelector />);
  
  // Select an option
  await user.selectOptions(
    screen.getByLabelText('Country'),
    'uk'
  );
  
  // Verify selection
  expect(screen.getByText('Selected: uk')).toBeInTheDocument();
  expect(screen.getByRole('combobox')).toHaveValue('uk');
});
                

Checking Checkboxes and Radio Buttons


function NewsletterForm() {
  const [agreed, setAgreed] = React.useState(false);
  const [frequency, setFrequency] = React.useState('');
  
  return (
    <form>
      <label>
        <input
          type="checkbox"
          checked={agreed}
          onChange={(e) => setAgreed(e.target.checked)}
        />
        I agree to receive newsletters
      </label>
      
      <fieldset>
        <legend>Frequency</legend>
        <label>
          <input
            type="radio"
            name="frequency"
            value="daily"
            checked={frequency === 'daily'}
            onChange={(e) => setFrequency(e.target.value)}
          />
          Daily
        </label>
        <label>
          <input
            type="radio"
            name="frequency"
            value="weekly"
            checked={frequency === 'weekly'}
            onChange={(e) => setFrequency(e.target.value)}
          />
          Weekly
        </label>
      </fieldset>
    </form>
  );
}

test('handles checkbox and radio interactions', async () => {
  const user = userEvent.setup();
  render(<NewsletterForm />);
  
  // Check the checkbox
  const checkbox = screen.getByRole('checkbox');
  await user.click(checkbox);
  expect(checkbox).toBeChecked();
  
  // Select a radio button
  await user.click(screen.getByRole('radio', { name: 'Weekly' }));
  expect(screen.getByRole('radio', { name: 'Weekly' })).toBeChecked();
  expect(screen.getByRole('radio', { name: 'Daily' })).not.toBeChecked();
});
                

Hovering


function Tooltip() {
  const [visible, setVisible] = React.useState(false);
  
  return (
    <div>
      <button
        onMouseEnter={() => setVisible(true)}
        onMouseLeave={() => setVisible(false)}
      >
        Hover me
      </button>
      {visible && <div role="tooltip">Tooltip content</div>}
    </div>
  );
}

test('shows tooltip on hover', async () => {
  const user = userEvent.setup();
  render(<Tooltip />);
  
  const button = screen.getByRole('button');
  
  // Tooltip not visible initially
  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
  
  // Hover over button
  await user.hover(button);
  expect(screen.getByRole('tooltip')).toBeInTheDocument();
  
  // Unhover
  await user.unhover(button);
  expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
                

βœ… user-event Methods

  • user.click(element) - Click an element
  • user.dblClick(element) - Double click
  • user.type(element, text) - Type text
  • user.clear(element) - Clear input
  • user.selectOptions(element, values) - Select dropdown options
  • user.deselectOptions(element, values) - Deselect options
  • user.upload(element, file) - Upload file
  • user.hover(element) - Hover over element
  • user.unhover(element) - Stop hovering
  • user.tab() - Tab to next element
  • user.keyboard(text) - Press keyboard keys

⏱️ Testing Async Behavior

Modern React applications are full of asynchronous operations β€” data fetching, delayed interactions, animations, debounced inputs. Testing these means learning to wait patiently instead of checking too soon.

Imagine ordering food at a counter. πŸ” If you demand your burger the instant you finish ordering, you'll be told there's nothing there β€” not because the order failed, but because it isn't ready yet. A synchronous getBy query is exactly that impatient customer: it looks once, immediately, and gives up. Async queries like findBy are the patient customer who waits by the counter and picks up the burger the moment it's placed down. The component isn't broken; your test just needs to wait for the right moment.

The Problem with Async

Consider this component that fetches data:


function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = React.useState<User | null>(null);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;
  
  return <div>{user.name}</div>;
}
                

If we test this synchronously, we'll only see the loading state:


// ❌ This test fails - doesn't wait for data
test('displays user name', () => {
  render(<UserProfile userId="123" />);
  
  // This will fail - still showing loading state!
  expect(screen.getByText('John Doe')).toBeInTheDocument();
});
                

Solution 1: findBy Queries

The simplest solution is using findBy queries, which automatically wait:


test('displays user name after loading', async () => {
  render(<UserProfile userId="123" />);
  
  // findBy waits up to 1000ms by default
  const userName = await screen.findByText('John Doe');
  expect(userName).toBeInTheDocument();
});
                

βœ… findBy Behavior

findBy queries:

  • Return a Promise that resolves when the element appears
  • Retry multiple times (default: every 50ms for up to 1000ms)
  • Reject if the element never appears
  • Are perfect for elements that appear after async operations

Under the hood, a findBy is just a getBy wrapped in waitFor β€” so reach for it whenever the element you want isn't on screen yet.

πŸ’‘ Where does the data come from? Mock the network, not your component

The examples below assume a fetch call resolves with test data. In a real suite, don't let your tests hit a live server. The modern, recommended approach is Mock Service Worker (MSW): it intercepts requests at the network layer, so your component runs its real fetch code and you control the response. That keeps tests fast, deterministic, and free of implementation coupling. For a quick one-off you can still stub global.fetch with vi.fn() (as the error-state example does), but MSW scales far better across a real project.

Solution 2: waitFor

For more complex async scenarios, use waitFor:


import { render, screen, waitFor } from '@testing-library/react';

test('handles multiple async updates', async () => {
  render(<ComplexComponent />);
  
  // Wait for multiple conditions
  await waitFor(() => {
    expect(screen.getByText('Data loaded')).toBeInTheDocument();
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
  });
});
                

Testing Loading States


test('shows loading state then data', async () => {
  render(<UserProfile userId="123" />);
  
  // Initially shows loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  // Wait for data to appear
  await screen.findByText('John Doe');
  
  // Loading state should be gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
                

Testing Error States


function UserProfileWithError({ userId }: { userId: string }) {
  const [user, setUser] = React.useState<User | null>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [loading, setLoading] = React.useState(true);
  
  React.useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>User not found</div>;
  
  return <div>{user.name}</div>;
}

test('displays error message when fetch fails', async () => {
  // Mock fetch to simulate failure
  global.fetch = vi.fn(() => 
    Promise.reject(new Error('Failed to fetch'))
  );
  
  render(<UserProfileWithError userId="123" />);
  
  // Wait for error message
  const errorMessage = await screen.findByText(/error/i);
  expect(errorMessage).toBeInTheDocument();
});
                

Custom Timeout

Adjust timeout for slower operations:


test('waits for slow operation', async () => {
  render(<SlowComponent />);
  
  // Wait up to 5 seconds
  const result = await screen.findByText('Done', {}, { timeout: 5000 });
  expect(result).toBeInTheDocument();
});

// Or with waitFor
test('waits with custom timeout', async () => {
  render(<SlowComponent />);
  
  await waitFor(
    () => {
      expect(screen.getByText('Done')).toBeInTheDocument();
    },
    { timeout: 5000 }
  );
});
                

Testing Debounced/Throttled Functions


function SearchWithDebounce() {
  const [query, setQuery] = React.useState('');
  const [results, setResults] = React.useState<string[]>([]);
  
  // Debounced search (waits 300ms after last keystroke)
  React.useEffect(() => {
    const timer = setTimeout(() => {
      if (query) {
        setResults([`Result for: ${query}`]);
      }
    }, 300);
    
    return () => clearTimeout(timer);
  }, [query]);
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map((result, i) => (
          <li key={i}>{result}</li>
        ))}
      </ul>
    </div>
  );
}

test('debounces search input', async () => {
  const user = userEvent.setup();
  render(<SearchWithDebounce />);
  
  const input = screen.getByPlaceholderText('Search...');
  
  // Type quickly
  await user.type(input, 'React');
  
  // Results shouldn't appear immediately
  expect(screen.queryByText(/Result for:/)).not.toBeInTheDocument();
  
  // Wait for debounced function to execute
  await screen.findByText('Result for: React');
  expect(screen.getByText('Result for: React')).toBeInTheDocument();
});
                

⚠️ Common Async Testing Mistakes

  1. Not using async/await: Always mark test functions as async when using findBy or waitFor
  2. Using getBy for async elements: Use findBy instead
  3. Not waiting long enough: Increase timeout if operations are slow
  4. Testing implementation timing: Don't test that something happens in exactly 300msβ€”test that it eventually happens

waitForElementToBeRemoved

Wait for elements to disappear:


import { waitForElementToBeRemoved } from '@testing-library/react';

test('loading spinner disappears after data loads', async () => {
  render(<DataComponent />);
  
  const spinner = screen.getByText('Loading...');
  
  // Wait for spinner to be removed
  await waitForElementToBeRemoved(spinner);
  
  // Data should now be visible
  expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
                

πŸ“˜ TypeScript and Testing

Tests are code too β€” and code deserves the same type safety as the app it verifies. TypeScript makes your tests autocomplete-friendly, catches mistakes like passing the wrong prop shape to a component before the test even runs, and turns your mocks and helpers into self-documenting building blocks. Let's explore the patterns that keep your test suite fully typed.

Typing Component Props


// Component with typed props
interface UserCardProps {
  user: {
    id: string;
    name: string;
    email: string;
  };
  onEdit?: (id: string) => void;
}

function UserCard({ user, onEdit }: UserCardProps) {
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      {onEdit && (
        <button onClick={() => onEdit(user.id)}>Edit</button>
      )}
    </div>
  );
}

// Type-safe test
test('displays user information', () => {
  const mockUser: UserCardProps['user'] = {
    id: '1',
    name: 'Alice',
    email: 'alice@example.com'
  };
  
  render(<UserCard user={mockUser} />);
  
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
                

Typing Mock Functions


import { vi } from 'vitest';

test('calls onEdit with correct user ID', async () => {
  const user = userEvent.setup();
  
  // Properly typed mock function.
  // Modern Vitest types the mock by its full call signature:
  const mockOnEdit = vi.fn<(id: string) => void>();
  
  const mockUser: UserCardProps['user'] = {
    id: '123',
    name: 'Bob',
    email: 'bob@example.com'
  };
  
  render(<UserCard user={mockUser} onEdit={mockOnEdit} />);
  
  await user.click(screen.getByRole('button', { name: 'Edit' }));
  
  expect(mockOnEdit).toHaveBeenCalledWith('123');
  expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
                

Typing Custom Render Functions

Create a custom render function with providers:


// test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { ThemeProvider } from './ThemeProvider';

interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  theme?: 'light' | 'dark';
}

function customRender(
  ui: ReactElement,
  { theme = 'light', ...options }: CustomRenderOptions = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
  }
  
  return render(ui, { wrapper: Wrapper, ...options });
}

// Re-export everything
export * from '@testing-library/react';
export { customRender as render };
                

// Using the custom render
import { render, screen } from './test-utils';

test('renders with theme provider', () => {
  render(<ThemedComponent />, { theme: 'dark' });
  expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
                

Typing Async Queries


test('properly types async queries', async () => {
  render(<AsyncComponent />);
  
  // findBy returns Promise<HTMLElement>
  const element: HTMLElement = await screen.findByText('Loaded');
  
  // Can assert on the element
  expect(element).toBeInTheDocument();
  expect(element).toHaveClass('success');
});
                

Typing User Event


import userEvent from '@testing-library/user-event';
import type { UserEvent } from '@testing-library/user-event';

test('types user event correctly', async () => {
  const user: UserEvent = userEvent.setup();
  
  render(<Form />);
  
  // All methods are properly typed
  await user.type(screen.getByRole('textbox'), 'test');
  await user.click(screen.getByRole('button'));
});
                

Asserting on Typed Elements


test('asserts on specific element types', () => {
  render(<Form />);
  
  // Get element as specific type
  const input = screen.getByRole('textbox') as HTMLInputElement;
  
  // Now TypeScript knows it's an input
  expect(input.value).toBe('');
  expect(input.type).toBe('text');
  
  // Or use type guards
  const button = screen.getByRole('button');
  if (button instanceof HTMLButtonElement) {
    expect(button.disabled).toBe(false);
  }
});
                

πŸ’‘ TypeScript Testing Benefits

  • Autocomplete: Your IDE suggests available matchers and methods
  • Type safety: Catch errors before running tests
  • Refactoring confidence: Type errors highlight tests that need updates
  • Documentation: Types serve as inline documentation

Common Type Issues and Solutions


// Problem: Type error with toBeInTheDocument
// Solution: Import jest-dom types
import '@testing-library/jest-dom';

// Problem: Element type is too generic
// Solution: Assert specific type
const input = screen.getByRole('textbox') as HTMLInputElement;

// Problem: Mock function type errors
// Solution: Type the mock by its call signature (modern Vitest)
const mockFn = vi.fn<(a: string, b: number) => boolean>();

// Problem: jest-dom matchers like toBeInTheDocument() are not recognized
// Solution: import '@testing-library/jest-dom' in your setup file and add
// its types to tsconfig. Modern @testing-library/jest-dom ships the type
// augmentation for you, so you almost never hand-write a `declare module`.
// In tsconfig.json:
//   "types": ["vitest/globals", "@testing-library/jest-dom"]
                

πŸ‹οΈ Hands-on Exercises

πŸ‹οΈ Exercise 1: Todo List Component

Objective: Test a complete todo list with add, delete, and toggle functionality.

Component to Test:


interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

export function TodoList() {
  const [todos, setTodos] = React.useState<Todo[]>([]);
  const [input, setInput] = React.useState('');
  
  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, {
        id: Date.now().toString(),
        text: input,
        completed: false
      }]);
      setInput('');
    }
  };
  
  const toggleTodo = (id: string) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const deleteTodo = (id: string) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
  
  return (
    <div>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Add todo..."
      />
      <button onClick={addTodo}>Add</button>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              aria-label={`Toggle ${todo.text}`}
            />
            <span style={{ 
              textDecoration: todo.completed ? 'line-through' : 'none' 
            }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
                    

Write Tests For:

  1. Adding a new todo
  2. Toggling a todo's completed status
  3. Deleting a todo
  4. Not adding empty todos
  5. Clearing input after adding
πŸ’‘ Hint

Use getByRole for buttons and inputs. Use getByPlaceholderText for the input field. Use getByLabelText for checkboxes with aria-labels.

βœ… Solution

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

describe('TodoList', () => {
  it('adds a new todo when Add button is clicked', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    const input = screen.getByPlaceholderText('Add todo...');
    const addButton = screen.getByRole('button', { name: 'Add' });
    
    await user.type(input, 'Buy groceries');
    await user.click(addButton);
    
    expect(screen.getByText('Buy groceries')).toBeInTheDocument();
  });
  
  it('toggles todo completed status', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    // Add a todo
    await user.type(screen.getByPlaceholderText('Add todo...'), 'Test todo');
    await user.click(screen.getByRole('button', { name: 'Add' }));
    
    // Toggle it
    const checkbox = screen.getByRole('checkbox', { name: 'Toggle Test todo' });
    await user.click(checkbox);
    
    expect(checkbox).toBeChecked();
    
    // Toggle again
    await user.click(checkbox);
    expect(checkbox).not.toBeChecked();
  });
  
  it('deletes a todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    // Add a todo
    await user.type(screen.getByPlaceholderText('Add todo...'), 'To delete');
    await user.click(screen.getByRole('button', { name: 'Add' }));
    
    expect(screen.getByText('To delete')).toBeInTheDocument();
    
    // Delete it
    await user.click(screen.getByRole('button', { name: 'Delete' }));
    
    expect(screen.queryByText('To delete')).not.toBeInTheDocument();
  });
  
  it('does not add empty todos', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    const addButton = screen.getByRole('button', { name: 'Add' });
    
    // Click Add without typing anything
    await user.click(addButton);
    
    // No todos should be added
    expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
  });
  
  it('clears input after adding todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    const input = screen.getByPlaceholderText('Add todo...');
    
    await user.type(input, 'New todo');
    await user.click(screen.getByRole('button', { name: 'Add' }));
    
    expect(input).toHaveValue('');
  });
});
                        

πŸ‹οΈ Exercise 2: Login Form with Validation

Objective: Test form validation and submission.

Component to Test:


interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = React.useState('');
  const [password, setPassword] = React.useState('');
  const [errors, setErrors] = React.useState<string[]>([]);
  const [loading, setLoading] = React.useState(false);
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const newErrors: string[] = [];
    if (!email) newErrors.push('Email is required');
    if (!email.includes('@')) newErrors.push('Email must be valid');
    if (!password) newErrors.push('Password is required');
    if (password.length < 6) newErrors.push('Password must be at least 6 characters');
    
    if (newErrors.length > 0) {
      setErrors(newErrors);
      return;
    }
    
    setErrors([]);
    setLoading(true);
    
    try {
      await onSubmit(email, password);
    } finally {
      setLoading(false);
    }
  };
  
  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)}
      />
      
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
      
      {errors.length > 0 && (
        <ul role="alert">
          {errors.map((error, i) => (
            <li key={i}>{error}</li>
          ))}
        </ul>
      )}
    </form>
  );
}
                    

Write Tests For:

  1. Shows validation errors for empty fields
  2. Shows validation error for invalid email
  3. Shows validation error for short password
  4. Calls onSubmit with correct values when valid
  5. Shows loading state during submission
βœ… Solution

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

describe('LoginForm', () => {
  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = vi.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(screen.getByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
  
  it('shows validation error for invalid email', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = vi.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText('Email'), 'notanemail');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(screen.getByText('Email must be valid')).toBeInTheDocument();
  });
  
  it('shows validation error for short password', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = vi.fn();
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), '123');
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(screen.getByText('Password must be at least 6 characters')).toBeInTheDocument();
  });
  
  it('calls onSubmit with correct values when valid', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = vi.fn().mockResolvedValue(undefined);
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(mockOnSubmit).toHaveBeenCalledWith('test@example.com', 'password123');
  });
  
  it('shows loading state during submission', async () => {
    const user = userEvent.setup();
    const mockOnSubmit = vi.fn(() => 
      new Promise(resolve => setTimeout(resolve, 100))
    );
    
    render(<LoginForm onSubmit={mockOnSubmit} />);
    
    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Login' }));
    
    expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
    
    await screen.findByRole('button', { name: 'Login' });
  });
});
                        

✨ Best Practices

βœ… Do's

1. Query by Accessibility First


// βœ… Good - accessible and resilient
screen.getByRole('button', { name: 'Submit' })
screen.getByLabelText('Email')

// ❌ Avoid - brittle and not accessibility-focused
screen.getByTestId('submit-btn')
container.querySelector('.email-input') // not user-facing at all
                

2. Use user-event Over fireEvent


// βœ… Good - realistic user interaction
const user = userEvent.setup();
await user.click(button);
await user.type(input, 'text');

// ❌ Avoid - less realistic
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'text' } });
                

3. Test Behavior, Not Implementation


// βœ… Good - tests user-facing behavior
test('increments counter', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  await user.click(screen.getByRole('button'));
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

// ❌ Avoid - tests implementation
test('calls setState with incremented value', () => {
  // Don't test internal state or methods
});
                

4. Use findBy for Async Elements


// βœ… Good - waits for element
test('loads data', async () => {
  render(<AsyncComponent />);
  expect(await screen.findByText('Data loaded')).toBeInTheDocument();
});

// ❌ Wrong - doesn't wait
test('loads data', () => {
  render(<AsyncComponent />);
  expect(screen.getByText('Data loaded')).toBeInTheDocument(); // Fails!
});
                

5. Use queryBy to Assert Absence


// βœ… Good - checks element doesn't exist
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// ❌ Wrong - throws error if not found
expect(screen.getByText('Error')).not.toBeInTheDocument();
                

❌ Don'ts

1. Don't Query by Classes or IDs


// ❌ Bad - implementation details
const element = container.querySelector('.my-class');
const element = container.querySelector('#my-id');

// βœ… Good - user-facing queries
screen.getByRole('button');
screen.getByText('Submit');
                

2. Don't Test Third-Party Libraries


// ❌ Don't test that React Router works
test('Route component renders', () => {
  render(<Route path="/" component={Home} />);
  // Testing React Router, not your code
});

// βœ… Test your component's behavior with routing
test('navigates to profile on click', async () => {
  const user = userEvent.setup();
  render(<App />);
  
  await user.click(screen.getByRole('link', { name: 'Profile' }));
  expect(screen.getByText('Profile Page')).toBeInTheDocument();
});
                

3. Don't Make Tests Depend on Each Other


// ❌ Bad - tests depend on order
let component;

test('renders component', () => {
  component = render(<MyComponent />);
});

test('handles click', () => {
  // Depends on previous test
  fireEvent.click(component.getByRole('button'));
});

// βœ… Good - independent tests
test('renders component', () => {
  render(<MyComponent />);
  expect(screen.getByRole('button')).toBeInTheDocument();
});

test('handles click', async () => {
  const user = userEvent.setup();
  render(<MyComponent />);
  await user.click(screen.getByRole('button'));
});
                

πŸ’‘ Pro Tips

1. Use screen.debug() for Troubleshooting


test('debugging test', () => {
  render(<MyComponent />);
  
  // See the entire DOM
  screen.debug();
  
  // See a specific element
  screen.debug(screen.getByRole('button'));
});
                

2. Create Custom Render Functions


// test-utils.tsx
export function renderWithProviders(ui: React.ReactElement) {
  return render(
    <ThemeProvider>
      <QueryClientProvider client={queryClient}>
        {ui}
      </QueryClientProvider>
    </ThemeProvider>
  );
}
                

3. Use Testing Playground


test('get query suggestions', () => {
  render(<MyComponent />);
  
  // Logs URL to testing playground
  screen.logTestingPlaygroundURL();
  
  // Visit the URL to see suggested queries
});
                

4. Test Error Boundaries


test('error boundary catches errors', () => {
  const ThrowError = () => {
    throw new Error('Test error');
  };
  
  render(
    <ErrorBoundary>
      <ThrowError />
    </ErrorBoundary>
  );
  
  expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
                

βœ… Testing Checklist

  • βœ“ Tests are independent and can run in any order
  • βœ“ Queries prioritize accessibility (role, label, text)
  • βœ“ user-event is used for interactions
  • βœ“ Async operations use findBy or waitFor
  • βœ“ Tests focus on user behavior, not implementation
  • βœ“ Error states and edge cases are tested
  • βœ“ Tests are well-named and describe behavior

πŸ“š Summary

πŸŽ‰ Key Takeaways

  • RTL Philosophy: Test components the way users interact with them, not how they're implemented
  • Query Priority: Use getByRole first, then getByLabelText, then getByText. Avoid getByTestId unless necessary
  • Three Query Types: getBy (element should exist), queryBy (checking absence), findBy (async elements)
  • user-event over fireEvent: Simulates realistic user interactions with proper event sequences
  • Async Testing: Use findBy queries or waitFor for elements that appear asynchronously
  • TypeScript Integration: Properly type props, mocks, and custom utilities for type safety
  • Accessibility Focus: Writing tests with RTL encourages accessible components
  • Test Behavior: Focus on what users see and do, not internal state or methods

πŸ“š Additional Resources

πŸš€ What's Next?

In the next lesson, we'll dive deeper into Testing User Interactions and explore:

  • Advanced form testing techniques
  • Testing custom hooks
  • Creating custom render functions with providers
  • Testing components with complex state
  • Testing context and global state
  • Practical patterns for real-world applications
πŸ’‘ Remember: The best tests are those that give you confidence your app works for users. If you can test it the way a user would use it, you're on the right track. Don't worry about coverage numbersβ€”worry about testing the right things!

πŸŽ‰ Congratulations!

You've mastered React Testing Library! You now know how to render components, query elements accessibly, simulate user interactions, and test async behavior. These skills will help you build robust, user-focused React applications with confidence.

Keep practicing, and remember: every test you write makes your application better!