TDD for React Components with React Testing Library

TDD for React Components with React Testing Library

Testing React components has a reputation for being awkward. Shallow rendering, mock components, snapshot tests that break on every change — these patterns produce test suites that slow teams down rather than help them. React Testing Library changed this by enforcing a simple rule: test components the way users use them, not the way developers implement them.

That philosophy pairs naturally with TDD. When your tests describe user interactions rather than implementation details, they stay valid through refactors. This tutorial applies the red-green-refactor cycle to React component development using React Testing Library and Jest.

Setup

Assuming you have a React project with Create React App or Vite. Install the testing library:

npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom

If using Vite, you also need:

npm install --save-dev vitest jsdom

Add to your setup file (e.g., src/setupTests.js):

import '@testing-library/jest-dom';

What We Are Building

A LoginForm component with two fields (email, password), a submit button, validation errors, and a loading state during submission. We will build it entirely test-first.

Round 1: Rendering the Form

Red:

// src/LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  test('renders email and password fields', () => {
    render(<LoginForm onSubmit={() => {}} />);
    
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
  });

  test('renders a submit button', () => {
    render(<LoginForm onSubmit={() => {}} />);
    
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
  });
});

Note: getByLabelText finds inputs by their associated label. getByRole finds by ARIA role. These are the user-facing queries — they would fail if the form was inaccessible.

Green:

// src/LoginForm.jsx
export default function LoginForm({ onSubmit }) {
  return (
    <form>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" />
      
      <label htmlFor="password">Password</label>
      <input id="password" type="password" />
      
      <button type="submit">Sign In</button>
    </form>
  );
}

Tests pass. Move on.

Round 2: User Input

Red:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

test('allows user to type email and password', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={() => {}} />);
  
  await user.type(screen.getByLabelText(/email/i), 'test@example.com');
  await user.type(screen.getByLabelText(/password/i), 'secretpassword');
  
  expect(screen.getByLabelText(/email/i)).toHaveValue('test@example.com');
  expect(screen.getByLabelText(/password/i)).toHaveValue('secretpassword');
});

This test actually passes with the current implementation because uncontrolled inputs track their own state. But we want controlled inputs, so we need to make it explicit. The test does not care about the implementation — it only cares that the values are there when you type them. Green.

Round 3: Form Submission

Red:

test('calls onSubmit with email and password when form is submitted', async () => {
  const user = userEvent.setup();
  const mockSubmit = jest.fn();
  
  render(<LoginForm onSubmit={mockSubmit} />);
  
  await user.type(screen.getByLabelText(/email/i), 'user@example.com');
  await user.type(screen.getByLabelText(/password/i), 'mypassword');
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(mockSubmit).toHaveBeenCalledTimes(1);
  expect(mockSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'mypassword',
  });
});

Green:

import { useState } from 'react';

export default function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    onSubmit({ email, password });
  }

  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">Sign In</button>
    </form>
  );
}

All tests pass. Green.

Round 4: Validation Errors

Red:

test('shows error when submitting with empty email', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={() => {}} />);
  
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});

test('shows error when submitting with empty password', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={() => {}} />);
  
  await user.type(screen.getByLabelText(/email/i), 'user@example.com');
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});

test('does not call onSubmit when validation fails', async () => {
  const user = userEvent.setup();
  const mockSubmit = jest.fn();
  render(<LoginForm onSubmit={mockSubmit} />);
  
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(mockSubmit).not.toHaveBeenCalled();
});

Green:

import { useState } from 'react';

export default function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});

  function validate() {
    const newErrors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    return newErrors;
  }

  function handleSubmit(e) {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    onSubmit({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {errors.email && <p role="alert">{errors.email}</p>}
      
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {errors.password && <p role="alert">{errors.password}</p>}
      
      <button type="submit">Sign In</button>
    </form>
  );
}

All 7 tests pass.

Round 5: Loading State

Red:

test('shows loading state while submission is pending', async () => {
  const user = userEvent.setup();
  // onSubmit returns a promise that does not resolve immediately
  const mockSubmit = jest.fn(() => new Promise(() => {}));
  
  render(<LoginForm onSubmit={mockSubmit} />);
  
  await user.type(screen.getByLabelText(/email/i), 'user@example.com');
  await user.type(screen.getByLabelText(/password/i), 'mypassword');
  await user.click(screen.getByRole('button', { name: /sign in/i }));
  
  expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
});

Green:

import { useState } from 'react';

export default function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  const [isLoading, setIsLoading] = useState(false);

  function validate() {
    const newErrors = {};
    if (!email) newErrors.email = 'Email is required';
    if (!password) newErrors.password = 'Password is required';
    return newErrors;
  }

  async function handleSubmit(e) {
    e.preventDefault();
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    setIsLoading(true);
    await onSubmit({ email, password });
    setIsLoading(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {errors.email && <p role="alert">{errors.email}</p>}
      
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {errors.password && <p role="alert">{errors.password}</p>}
      
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing In...' : 'Sign In'}
      </button>
    </form>
  );
}

All 8 tests pass.

Refactor: Extract the validate function and the error display into their own components or hooks. The tests do not care — they only test rendered output and user interactions.

Why React Testing Library Is Right for TDD

Traditional React testing approaches (Enzyme shallow rendering, snapshot tests) create tests tightly coupled to component internals. When you refactor — extract a hook, rename a state variable, split a component — the tests break even though the behavior is unchanged. That is the opposite of what you want from TDD.

React Testing Library tests what the user sees and does:

  • getByLabelText finds inputs by label — fails if labels are missing (accessibility bug)
  • getByRole finds elements by ARIA role — works regardless of HTML tag
  • userEvent simulates real browser events — not synthetic React events

When you refactor LoginForm to use useReducer instead of useState, or move validation to a custom hook, or add Redux — the tests do not change. The component still renders email and password fields, still calls onSubmit with the right values, still shows validation errors. That is the stability TDD promises.

The Rule: Test Behavior, Not Structure

The final test suite for LoginForm is 8 tests that cover:

  1. Form renders with the right fields
  2. Submit button is present
  3. User can type in fields
  4. Submission calls handler with correct values
  5. Empty email triggers error
  6. Empty password triggers error
  7. Validation prevents submission
  8. Loading state during async submission

Not one test checks whether the component uses useState or useReducer. Not one test checks the component's internal structure. All 8 tests would survive a complete internal rewrite, as long as the external behavior stays the same. That is what good TDD looks like.

Read more