Skip to content

Join the Seedly owners community →

Testing & Debugging

Testing React Components

Learn to test React components with React Testing Library and user-centric approaches

Written by 15 min read1 activity
Buzz, your presenter

Buzz presents

React Testing Library helps you test what people see and click, so your tests dont break every time you tidy up!React Testing Library helps you test what people see and click, so your tests dont break every time you tidy up!

Buzz presses a music box button and watches the dancer spin, back panel closed
Test what users see and click, not the insides

Testing React components feels a lil different from testing regular functions. You're not checking return values anymore. You're checking what the user SEES, and how the component reacts when they poke at it. The golden rule here is simple... test your components the way a real person would use them.

React Testing Library Philosophy#

React Testing Library is built on one simple idea. If your tests use your components the same way people do, your tests end up more reliable and a lot more meaningful.

So instead of poking at internal state or behind-the-scenes details, you do this.

  • Find elements by their text, labels, or roles (how users find them)
  • Click buttons, type in inputs, submit forms (how users interact)
  • Check what's actually visible on screen (what users see)

Setting It Up#

React Testing Library needs a fake browser to render into, plus a few extra packages. Install them as dev dependencies.

npm install -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event jsdom

Then tell Vitest to use that fake browser (it's called jsdom) and load the extra matchers like toBeInTheDocument before every test.

// vitest.config.ts
import { defineConfig } from 'vitest/config';
 
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest-setup.ts'],
  },
});
// vitest-setup.ts
import '@testing-library/jest-dom/vitest';

Skip the setup file and every toBeInTheDocument check blows up, because Vitest doesn't ship those matchers on its own.

Here's a first test for a lil Counter component (one that shows Count: 0 and has an Increment button, like the one from the React module). The test has JSX in it, so name the file Counter.test.tsx and not .ts.

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';
 
describe('Counter', () => {
  it('should increment when the button is clicked', () => {
    render(<Counter />);
 
    // Find the button like a user would
    const button = screen.getByRole('button', { name: /increment/i });
 
    // Click it like a user would
    fireEvent.click(button);
 
    // Check what the user would see
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

The Render, Screen, and FireEvent Trio#

These three tools cover most of what you'll do when testing React.

Step 1. render() - Put the Component on Screen#

The render function puts your component on a simulated browser page that lives in memory. Think of it as loading a web page in an invisible browser.

Step 2. screen - Find Elements#

The screen object is how you go looking for elements. You'll use queries like getByText, getByRole, getByLabelText, or getByPlaceholderText.

Step 3. fireEvent - Simulate Interactions#

The fireEvent object lets you fake clicks, typing, form submissions, and whatever else a user might do.

Finding Elements#

Picking the right query matters. Here's the order Testing Library recommends you try them in.

// Best: Accessible to everyone (screen readers, etc.)
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email address');
 
// Good: Visible text
screen.getByText('Welcome back!');
screen.getByPlaceholderText('Enter your name');
 
// Okay: Test utilities
screen.getByTestId('custom-element');

Testing User Interactions#

Here's a fuller example that tests a login form.

import { render, screen, fireEvent } 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('should show error message for empty email', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);
 
    // Click submit without entering email
    const submitButton = screen.getByRole('button', { name: /sign in/i });
    fireEvent.click(submitButton);
 
    // Error message should appear
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });
 
  it('should call onSubmit with form data', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();
    render(<LoginForm onSubmit={handleSubmit} />);
 
    // Fill in the form
    const emailInput = screen.getByLabelText(/email/i);
    const passwordInput = screen.getByLabelText(/password/i);
 
    await user.type(emailInput, '[email protected]');
    await user.type(passwordInput, 'password123');
 
    // Submit the form
    await user.click(screen.getByRole('button', { name: /sign in/i }));
 
    // Check the callback was called correctly
    expect(handleSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'password123'
    });
  });
});

See that userEvent.setup() at the top of the second test? That's the way the Testing Library docs recommend using userEvent today. Make one user per test, then await every click and keystroke it does.

Testing Async Behavior#

Buzz holds a photo of flowers beside the real vase to compare them
Snapshot tests flag anything that changed from before

Lots of components fetch data or wait around for something to finish. For that async content, use waitFor or findBy queries.

describe('UserProfile', () => {
  it('should display user data after loading', async () => {
    render(<UserProfile userId="123" />);
 
    // Initially shows loading state
    expect(screen.getByText(/loading/i)).toBeInTheDocument();
 
    // Wait for the data to appear
    const userName = await screen.findByText('Alice Johnson');
    expect(userName).toBeInTheDocument();
 
    // Loading should be gone
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });
});

Testing Component Props#

Check that your components render the right way with different props.

describe('Alert', () => {
  it('should render success style for success type', () => {
    render(<Alert type="success" message="Saved!" />);
 
    const alert = screen.getByRole('alert');
    expect(alert).toHaveClass('alert-success');
    expect(alert).toHaveTextContent('Saved!');
  });
 
  it('should render error style for error type', () => {
    render(<Alert type="error" message="Something went wrong" />);
 
    const alert = screen.getByRole('alert');
    expect(alert).toHaveClass('alert-error');
  });
});

Snapshot Testing#

Snapshot tests grab the rendered output and compare it to a saved copy. They're great for catching UI changes you didn't mean to make.

describe('Button', () => {
  it('should match snapshot', () => {
    const { container } = render(
      <Button variant="primary" size="large">
        Click me
      </Button>
    );
 
    expect(container).toMatchSnapshot();
  });
});

The first time this test runs, it saves a snapshot. Every run after that compares against the saved copy. If the output changes, the test fails, and then it's on you to decide whether that change was on purpose.

TL;DR#

  • Test components the way real users use them
  • Reach for getByRole and getByLabelText first, since they're the accessible queries
  • Use fireEvent or userEvent to fake user interactions
  • Handle async content with findBy or waitFor
  • Snapshot tests catch surprise UI changes automatically
  • Don't test behind-the-scenes details like internal state

What's Next?#

Tests catch a LOT, but sooner or later something's gonna break anyway. When it does, you need a plan. Next up is debugging techniques, so you can track bugs down and squash them without losing your mind...

This lesson ends with a short activity.