Week 13

Introduction to Testing

Testing React Components with RTL

Chapter 3

Practice

Assignment

Front end Track

How React Testing Library thinks

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>


The query hierarchy

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:


Testing a component: ProjectCard

Let'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.

The component

// 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>
  )
}

The test file

// 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:


Testing user interaction: ContactForm

Now let's test something with actual user interaction. A contact form has inputs, a submit button, and validation messages; perfect for testing behaviour.

The component

// 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>
  )
}

The test file

// 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>


Async testing

Sometimes your component fetches data or waits for something before rendering. For these cases you need findBy queries and waitFor.

Mocking API calls with vi.mock

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>


Additional Resources

Videos

https://www.youtube.com/watch?v=7dTTFW7yACQ&list=PL4cUxeGkcC9gm4_-5UsNmLqMosM-dzuvQ

Reading


<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/*

CC BY-NC-SA 4.0 Icons

Built with โค๏ธ by the HackYourFuture community ยท Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.