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 Jest. But React applications aren't just functionsβ€”they're interactive user interfaces built with components. How do we test something visual and interactive?

Enter React Testing Library (RTL)β€”the most popular and recommended way to test React components. It's built on a simple but powerful philosophy: test your components the way users use them.

πŸ“– 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] A --> D[Test 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
test('counter increments state', () => {
  const { container } = render(<Counter />);
  const component = container.querySelector('.counter');
  
  // Testing internal state - BAD!
  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', () => {
  render(<Counter />);
  
  // Test what the user sees
  expect(screen.getByText('Count: 0')).toBeInTheDocument();
  
  // Test user interaction
  fireEvent.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, it might not be accessible to screen reader users. This makes your tests a tool for improving accessibility!

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 libraries
npm install --save-dev @testing-library/react
npm install --save-dev @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

# TypeScript types
npm install --save-dev @types/jest
npm install --save-dev @types/react
npm install --save-dev @types/react-dom

# If using Vitest (for Vite projects)
npm install --save-dev vitest jsdom @vitest/ui
                

πŸ’‘ Package Breakdown

  • @testing-library/react: Core RTL functionality for rendering and querying
  • @testing-library/jest-dom: Custom matchers for asserting on DOM nodes
  • @testing-library/user-event: Realistic user interaction simulation
  • jsdom: Browser-like environment for Node.js

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 in a test environment.

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 in a test environment and provides utilities to query and interact with it. It returns an object with query methods, but we usually use the screen object instead for more readable tests.

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

React Testing Library provides three types of query methods, each with different behavior. Understanding when to use each type is crucial for writing effective tests.

Query Types: getBy, queryBy, findBy

Each query comes in three variants with different behaviors:

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. We'll use @testing-library/user-event for realistic user interactions.

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. Testing these requires special techniques to wait for changes to happen.

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 element never appears
  • Perfect for elements that appear after async operations

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

TypeScript provides excellent type safety for your tests. Let's explore how to write properly typed tests.

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
  const mockOnEdit = vi.fn<[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: Explicitly type the mock
const mockFn = vi.fn<[string, number], boolean>();

// Problem: Custom matcher not recognized
// Solution: Extend jest matchers
declare global {
  namespace Vi {
    interface Matchers<R> {
      toBeInTheDocument(): R;
    }
  }
}
                

πŸ‹οΈ 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')
screen.getByClassName('email-input')
                

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!