Testing React Components with RTL
React Testing Library has one central philosophy: test your components the way a user uses them.
A user doesn't know or care whether your component uses useState or useReducer. They see a button, they click it, and they expect something to happen. RTL pushes you to write tests that reflect exactly that.
This means you query elements the way a user or assistive technology would find them; by their visible text, their role, or their label; not by class names or internal IDs that could change at any time.
<aside> ๐ก
If your test breaks when you rename a CSS class but not when you change the text on a button, something is wrong. RTL queries should reflect what users actually interact with.
</aside>
RTL gives you several ways to find elements. Use them in this order of preference:
| Query | When to use |
|---|---|
getByRole |
Almost always your first choice; buttons, headings, inputs, links |
getByLabelText |
Form inputs associated with a label |
getByText |
Non-interactive text content |
getByPlaceholderText |
Last resort for inputs without labels |
getByTestId |
Only when nothing else works; add data-testid to the element |
<aside> ๐ก
getByRole and getByLabelText are not just convenient; they enforce accessibility. If you can't find your button with getByRole('button', { name: 'Submit' }), it likely has an accessibility problem.
</aside>
The difference between getBy, queryBy, and findBy:
getBy โ throws if the element isn't found; use when the element should be therequeryBy โ returns null if not found; use when asserting something is absentfindBy โ returns a Promise; use for elements that appear asynchronouslyLet's start with something straightforward. A ProjectCard component receives a project as a prop and renders its title, description, and a link to the live demo.
// src/components/ProjectCard.tsx
type Props = {
title: string
description: string
demoUrl: string
techStack: string[]
}
export function ProjectCard({ title, description, demoUrl, techStack }: Props) {
return (
<article>
<h3>{title}</h3>
<p>{description}</p>
<ul>
{techStack.map(tech => (
<li key={tech}>{tech}</li>
))}
</ul>
<a href={demoUrl}>View demo</a>
</article>
)
}
// src/components/ProjectCard.test.tsx
import { render, screen } from '@testing-library/react'
import { ProjectCard } from './ProjectCard'
const defaultProps = {
title: 'Portfolio',
description: 'My personal portfolio site',
demoUrl: '<https://example.com>',
techStack: ['React', 'TypeScript'],
}
describe('ProjectCard', () => {
it('renders the project title', () => {
render(<ProjectCard {...defaultProps} />)
expect(screen.getByRole('heading', { name: 'Portfolio' })).toBeInTheDocument()
})
it('renders all tech stack items', () => {
render(<ProjectCard {...defaultProps} />)
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('TypeScript')).toBeInTheDocument()
})
it('renders a link to the demo', () => {
render(<ProjectCard {...defaultProps} />)
const link = screen.getByRole('link', { name: 'View demo' })
expect(link).toHaveAttribute('href', '<https://example.com>')
})
})
A few things worth noticing:
render mounts the component into the jsdom environmentscreen is your access point to the rendered DOM; always use screen rather than destructuring from renderdefaultProps to avoid repeating the same props in every test; override only what a specific test needsNow let's test something with actual user interaction. A contact form has inputs, a submit button, and validation messages; perfect for testing behaviour.
// src/components/ContactForm.tsx
import { useState } from 'react'
export function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [submitted, setSubmitted] = useState(false)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || !email) {
setError('Please fill in all fields')
return
}
setSubmitted(true)
}
if (submitted) {
return <p>Thanks for reaching out, {name}!</p>
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={e => setName(e.target.value)}
/>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Send message</button>
</form>
)
}
// src/components/ContactForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactForm } from './ContactForm'
describe('ContactForm', () => {
it('renders the form fields', () => {
render(<ContactForm />)
expect(screen.getByLabelText('Name')).toBeInTheDocument()
expect(screen.getByLabelText('Email')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Send message' })).toBeInTheDocument()
})
it('shows an error when submitted with empty fields', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.click(screen.getByRole('button', { name: 'Send message' }))
expect(screen.getByRole('alert')).toHaveTextContent('Please fill in all fields')
})
it('shows a success message after valid submission', async () => {
const user = userEvent.setup()
render(<ContactForm />)
await user.type(screen.getByLabelText('Name'), 'Anna')
await user.type(screen.getByLabelText('Email'), '[email protected]')
await user.click(screen.getByRole('button', { name: 'Send message' }))
expect(screen.getByText('Thanks for reaching out, Anna!')).toBeInTheDocument()
})
})
<aside> ๐ก
Always use userEvent over fireEvent for simulating interactions. userEvent.type simulates real keystrokes including focus, input, and change events. fireEvent is a lower-level utility that skips several of those steps and can give you false positives.
</aside>
<aside> โ ๏ธ
userEvent.setup() must be called before render. It sets up a user session that properly handles pointer and keyboard events. Calling it after render can cause subtle timing issues.
</aside>
Sometimes your component fetches data or waits for something before rendering. For these cases you need findBy queries and waitFor.
In tests, you never want to make real network requests. They're slow, unreliable, and you can't control what they return. Instead, you mock the module responsible for fetching.
Here's a component that fetches and displays projects:
// src/components/ProjectList.tsx
import { useEffect, useState } from 'react'
import { fetchProjects } from '../api/projects'
export function ProjectList() {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProjects().then(data => {
setProjects(data)
setLoading(false)
})
}, [])
if (loading) return <p>Loading projects...</p>
return (
<ul>
{projects.map(p => (
<li key={p.id}>{p.title}</li>
))}
</ul>
)
}
And the test:
// src/components/ProjectList.test.tsx
import { render, screen } from '@testing-library/react'
import { ProjectList } from './ProjectList'
import { fetchProjects } from '../api/projects'
vi.mock('../api/projects')
const mockFetchProjects = vi.mocked(fetchProjects)
describe('ProjectList', () => {
it('shows a loading state initially', () => {
mockFetchProjects.mockResolvedValue([])
render(<ProjectList />)
expect(screen.getByText('Loading projects...')).toBeInTheDocument()
})
it('renders projects after loading', async () => {
mockFetchProjects.mockResolvedValue([
{ id: 1, title: 'Portfolio' },
{ id: 2, title: 'Weather App' },
])
render(<ProjectList />)
expect(await screen.findByText('Portfolio')).toBeInTheDocument()
expect(screen.getByText('Weather App')).toBeInTheDocument()
})
})
vi.mock('../api/projects') replaces the entire module with an auto-mocked version. vi.mocked() gives you TypeScript-aware access to the mock so you can control what it returns per test with mockResolvedValue.
<aside> โ ๏ธ
findByText (note the find) returns a Promise and will wait up to 1000ms for the element to appear. Use findBy whenever you're waiting for something async to resolve.
</aside>
https://www.youtube.com/watch?v=7dTTFW7yACQ&list=PL4cUxeGkcC9gm4_-5UsNmLqMosM-dzuvQ
<aside> ๐
Note for instructors: The component examples above use a basic useState form. If trainees have already built their contact form with React Hook Form (introduced in week 9), encourage them to test that version instead. The queries and assertions stay the same; only the component internals differ.
</aside>
The HackYourFuture curriculum is licensed underย CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.