βοΈ 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:
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:
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
- Can you use
getByRole? Use it! (Most accessible) - Is it a form field with a label? Use
getByLabelText - Is it text content? Use
getByText - Is it async? Use
findBy... - Checking absence? Use
queryBy... - Multiple elements? Use
getAllBy...orfindAllBy... - 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 elementuser.dblClick(element)- Double clickuser.type(element, text)- Type textuser.clear(element)- Clear inputuser.selectOptions(element, values)- Select dropdown optionsuser.deselectOptions(element, values)- Deselect optionsuser.upload(element, file)- Upload fileuser.hover(element)- Hover over elementuser.unhover(element)- Stop hoveringuser.tab()- Tab to next elementuser.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
- Not using async/await: Always mark test functions as
asyncwhen usingfindByorwaitFor - Using getBy for async elements: Use
findByinstead - Not waiting long enough: Increase timeout if operations are slow
- 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:
- Adding a new todo
- Toggling a todo's completed status
- Deleting a todo
- Not adding empty todos
- 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:
- Shows validation errors for empty fields
- Shows validation error for invalid email
- Shows validation error for short password
- Calls onSubmit with correct values when valid
- 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
- React Testing Library Documentation - Official docs and guides
- Query Priority Guide - Which query to use when
- Testing Playground - Interactive tool to find the best queries
- Common RTL Mistakes - Kent C. Dodds' guide to avoiding pitfalls
- user-event Documentation - Complete guide to user interactions
π 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!