Learn Jest in 10 DaysDay 6: Testing React Components
Chapter 6Learn Jest in 10 Days

Day 6: Testing React Components

What You'll Learn Today

  • What React Testing Library is and its guiding philosophy
  • Rendering components with render and querying the DOM with screen
  • Simulating user interactions with fireEvent and userEvent
  • Understanding getBy / queryBy / findBy query types
  • Testing forms and controlled inputs
  • Testing custom hooks with renderHook
  • Building a complete test suite for a Todo list component

Why React Testing Library?

Traditional UI testing focused on testing component internals β€” state values, lifecycle methods, and implementation details. React Testing Library (RTL) takes a different approach: test your components the way users interact with them.

"The more your tests resemble the way your software is used, the more confidence they can give you." β€” Kent C. Dodds, creator of Testing Library

flowchart LR
    subgraph Old["Enzyme (Implementation-focused)"]
        A1["Access component state"]
        A2["Check lifecycle methods"]
        A3["Shallow rendering"]
    end
    subgraph New["RTL (User-focused)"]
        B1["Query by text/role"]
        B2["Simulate clicks/typing"]
        B3["Assert visible output"]
    end
    Old -->|"Shift"| New
    style Old fill:#ef4444,color:#fff
    style New fill:#22c55e,color:#fff
Aspect Enzyme React Testing Library
Philosophy Test internals Test behavior
Queries By component/prop By role/text/label
State access Direct Not available
Refactor resilience Low High
Recommended by React No Yes

If you're new to React, check out "Learn React in 10 Days" in this series for a comprehensive introduction to components, state, and hooks.


Setting Up

Install the required packages:

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

Add the custom matchers to your test setup:

// jest.setup.js
require('@testing-library/jest-dom');

TypeScript version:

// jest.setup.ts
import '@testing-library/jest-dom';

Then reference it in your Jest config:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterSetup: ['./jest.setup.js'],
};

Rendering and Querying: render & screen

The two most fundamental APIs in RTL are render and screen.

// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;

TypeScript version:

// Greeting.tsx
interface GreetingProps {
  name: string;
}

function Greeting({ name }: GreetingProps) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);

  // Query the rendered DOM
  const heading = screen.getByText('Hello, Alice!');
  expect(heading).toBeInTheDocument();
  expect(heading.tagName).toBe('H1');
});

TypeScript test version:

// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

test('renders greeting with name', () => {
  render(<Greeting name="Alice" />);

  const heading = screen.getByText('Hello, Alice!');
  expect(heading).toBeInTheDocument();
  expect(heading.tagName).toBe('H1');
});
flowchart TB
    subgraph Flow["render & screen Flow"]
        R["render(<Component />)"] --> VDOM["Creates virtual DOM"]
        VDOM --> DOM["Attaches to jsdom"]
        DOM --> S["screen.getByXxx()"]
        S --> ASSERT["expect(...).toBeInTheDocument()"]
    end
    style Flow fill:#3b82f6,color:#fff

Common Custom Matchers from jest-dom

Matcher Purpose
toBeInTheDocument() Element exists in the DOM
toBeVisible() Element is visible to the user
toHaveTextContent(text) Element contains text
toHaveAttribute(attr, value) Element has an attribute
toBeDisabled() Element is disabled
toHaveClass(className) Element has a CSS class
toHaveValue(value) Input has a value

Query Types: getBy / queryBy / findBy

RTL provides three variants of every query, each suited for different scenarios.

Variant Returns Throws on no match Use case
getBy Element Yes Element should exist
queryBy Element or null No Assert element does NOT exist
findBy Promise<Element> Yes (rejects) Wait for async element
// StatusMessage.jsx
import { useState, useEffect } from 'react';

function StatusMessage({ userId }) {
  const [status, setStatus] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}/status`)
      .then(res => res.json())
      .then(data => {
        setStatus(data.status);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <p>Status: {status}</p>;
}

export default StatusMessage;

TypeScript version:

// StatusMessage.tsx
import { useState, useEffect } from 'react';

interface StatusMessageProps {
  userId: number;
}

function StatusMessage({ userId }: StatusMessageProps) {
  const [status, setStatus] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}/status`)
      .then(res => res.json())
      .then(data => {
        setStatus(data.status);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <p>Status: {status}</p>;
}

export default StatusMessage;
// StatusMessage.test.jsx
import { render, screen } from '@testing-library/react';
import StatusMessage from './StatusMessage';

beforeEach(() => {
  // Mock the fetch API
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ status: 'online' }),
    })
  );
});

test('shows loading then status', async () => {
  render(<StatusMessage userId={1} />);

  // getBy β€” element should exist right now
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // findBy β€” wait for async update
  const statusEl = await screen.findByText('Status: online');
  expect(statusEl).toBeInTheDocument();

  // queryBy β€” assert element is gone
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
flowchart TB
    subgraph Queries["Query Decision Tree"]
        Q["Need to find an element?"]
        Q -->|"Must exist now"| GET["getBy\nThrows if missing"]
        Q -->|"Might not exist"| QUERY["queryBy\nReturns null"]
        Q -->|"Will appear async"| FIND["findBy\nAwaits appearance"]
    end
    style GET fill:#22c55e,color:#fff
    style QUERY fill:#f59e0b,color:#fff
    style FIND fill:#8b5cf6,color:#fff

Query Suffixes

Each variant has multiple query methods based on how you search:

Query Searches by
ByRole ARIA role (button, heading, textbox)
ByLabelText Associated label text
ByPlaceholderText Placeholder attribute
ByText Visible text content
ByDisplayValue Current input value
ByAltText Image alt text
ByTitle Title attribute
ByTestId data-testid attribute

Priority: Prefer ByRole > ByLabelText > ByText > ByTestId. Use ByTestId only as a last resort.


User Interactions: fireEvent vs userEvent

fireEvent β€” Basic Events

fireEvent dispatches DOM events directly. It is simple but does not fully simulate real user behavior.

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

export default Counter;
// Counter.test.jsx (fireEvent)
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('increments count on button click', () => {
  render(<Counter />);

  const button = screen.getByRole('button', { name: 'Increment' });
  fireEvent.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

userEvent β€” Realistic Interactions

userEvent simulates full user interaction sequences (focus, keydown, keyup, input, etc.), giving more realistic test behavior.

// Counter.test.jsx (userEvent)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments count on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: 'Increment' });
  await user.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

TypeScript test version:

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

test('increments count on button click', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: 'Increment' });
  await user.click(button);

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Feature fireEvent userEvent
Event dispatch Single event Full interaction sequence
Focus handling Manual Automatic
Typing Sets value directly Types character by character
Realism Low High
Async No Yes (returns Promise)

Recommendation: Always prefer userEvent over fireEvent. It catches more real-world bugs.


Testing Forms

Forms involve multiple interactive elements β€” inputs, selects, checkboxes, and submission. Here is a complete example.

// LoginForm.jsx
import { useState } from 'react';

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

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!email || !password) {
      setError('All fields are required');
      return;
    }
    if (password.length < 8) {
      setError('Password must be at least 8 characters');
      return;
    }
    setError('');
    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)}
      />

      {error && <p role="alert">{error}</p>}

      <button type="submit">Sign In</button>
    </form>
  );
}

export default LoginForm;

TypeScript version:

// LoginForm.tsx
import { useState, FormEvent } from 'react';

interface LoginData {
  email: string;
  password: string;
}

interface LoginFormProps {
  onSubmit: (data: LoginData) => void;
}

function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    if (!email || !password) {
      setError('All fields are required');
      return;
    }
    if (password.length < 8) {
      setError('Password must be at least 8 characters');
      return;
    }
    setError('');
    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)}
      />

      {error && <p role="alert">{error}</p>}

      <button type="submit">Sign In</button>
    </form>
  );
}

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

describe('LoginForm', () => {
  const mockSubmit = jest.fn();

  beforeEach(() => {
    mockSubmit.mockClear();
  });

  test('submits form with valid data', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockSubmit} />);

    // Fill in the form using labels
    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'securepass');

    // Submit the form
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    // Verify submission
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'securepass',
    });
    expect(mockSubmit).toHaveBeenCalledTimes(1);
  });

  test('shows error when fields are empty', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockSubmit} />);

    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(screen.getByRole('alert')).toHaveTextContent(
      'All fields are required'
    );
    expect(mockSubmit).not.toHaveBeenCalled();
  });

  test('shows error for short password', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'short');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(screen.getByRole('alert')).toHaveTextContent(
      'Password must be at least 8 characters'
    );
    expect(mockSubmit).not.toHaveBeenCalled();
  });
});
flowchart TB
    subgraph FormTest["Form Testing Strategy"]
        FILL["Fill inputs\n(user.type)"]
        SELECT["Select options\n(user.selectOptions)"]
        CHECK["Toggle checkboxes\n(user.click)"]
        SUBMIT["Click submit\n(user.click)"]
        VERIFY["Assert callback / error"]
    end
    FILL --> SUBMIT
    SELECT --> SUBMIT
    CHECK --> SUBMIT
    SUBMIT --> VERIFY
    style FormTest fill:#8b5cf6,color:#fff

Testing Custom Hooks with renderHook

Custom hooks cannot be called outside of a component. The renderHook utility from RTL solves this by wrapping the hook in a test component.

// useCounter.js
import { useState, useCallback } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

export default useCounter;

TypeScript version:

// useCounter.ts
import { useState, useCallback } from 'react';

interface UseCounterReturn {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

function useCounter(initialValue: number = 0): UseCounterReturn {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  test('starts with initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('defaults to 0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('increments the count', () => {
    const { result } = renderHook(() => useCounter());

    // Wrap state updates in act()
    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  test('decrements the count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(10);
  });
});

TypeScript test version:

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  test('starts with initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increments the count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

Key point: Always wrap state-updating calls in act() when using renderHook. This ensures React processes all updates before you assert.


Practical Example: Todo List Component

Let's bring everything together by building and testing a full Todo list component.

The Component

// TodoList.jsx
import { useState } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    const text = input.trim();
    if (!text) return;
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
    setInput('');
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <div>
        <label htmlFor="new-todo">New Todo</label>
        <input
          id="new-todo"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="What needs to be done?"
        />
        <button onClick={addTodo}>Add</button>
      </div>

      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <label>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleTodo(todo.id)}
                />
                <span style={{
                  textDecoration: todo.completed ? 'line-through' : 'none'
                }}>
                  {todo.text}
                </span>
              </label>
              <button onClick={() => deleteTodo(todo.id)} aria-label={`Delete ${todo.text}`}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}

      <p>{todos.filter(t => !t.completed).length} items remaining</p>
    </div>
  );
}

export default TodoList;

TypeScript version:

// TodoList.tsx
import { useState } from 'react';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    const text = input.trim();
    if (!text) return;
    setTodos([...todos, { id: Date.now(), text, completed: false }]);
    setInput('');
  };

  const toggleTodo = (id: number) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <div>
        <label htmlFor="new-todo">New Todo</label>
        <input
          id="new-todo"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="What needs to be done?"
        />
        <button onClick={addTodo}>Add</button>
      </div>

      {todos.length === 0 ? (
        <p>No todos yet. Add one above!</p>
      ) : (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>
              <label>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => toggleTodo(todo.id)}
                />
                <span style={{
                  textDecoration: todo.completed ? 'line-through' : 'none'
                }}>
                  {todo.text}
                </span>
              </label>
              <button onClick={() => deleteTodo(todo.id)} aria-label={`Delete ${todo.text}`}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}

      <p>{todos.filter(t => !t.completed).length} items remaining</p>
    </div>
  );
}

export default TodoList;

The Tests

// TodoList.test.jsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';

describe('TodoList', () => {
  test('renders empty state', () => {
    render(<TodoList />);

    expect(screen.getByText('Todo List')).toBeInTheDocument();
    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
    expect(screen.getByText('0 items remaining')).toBeInTheDocument();
  });

  test('adds a new todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // Type into the input
    await user.type(screen.getByLabelText('New Todo'), 'Buy groceries');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    // Verify the todo appears
    expect(screen.getByText('Buy groceries')).toBeInTheDocument();
    expect(screen.getByText('1 items remaining')).toBeInTheDocument();

    // Input should be cleared
    expect(screen.getByLabelText('New Todo')).toHaveValue('');
  });

  test('does not add empty todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  test('adds multiple todos', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // Add first todo
    await user.type(screen.getByLabelText('New Todo'), 'Task 1');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    // Add second todo
    await user.type(screen.getByLabelText('New Todo'), 'Task 2');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    expect(screen.getByText('Task 1')).toBeInTheDocument();
    expect(screen.getByText('Task 2')).toBeInTheDocument();
    expect(screen.getByText('2 items remaining')).toBeInTheDocument();
  });

  test('toggles a todo as completed', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // Add a todo
    await user.type(screen.getByLabelText('New Todo'), 'Learn Jest');
    await user.click(screen.getByRole('button', { name: 'Add' }));

    // Toggle completion
    const checkbox = screen.getByRole('checkbox');
    await user.click(checkbox);

    expect(checkbox).toBeChecked();
    expect(screen.getByText('0 items remaining')).toBeInTheDocument();

    // Toggle back
    await user.click(checkbox);

    expect(checkbox).not.toBeChecked();
    expect(screen.getByText('1 items remaining')).toBeInTheDocument();
  });

  test('deletes a todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // Add a todo
    await user.type(screen.getByLabelText('New Todo'), 'Temporary task');
    await user.click(screen.getByRole('button', { name: 'Add' }));
    expect(screen.getByText('Temporary task')).toBeInTheDocument();

    // Delete it
    await user.click(screen.getByRole('button', { name: 'Delete Temporary task' }));

    // Verify it is gone
    expect(screen.queryByText('Temporary task')).not.toBeInTheDocument();
    expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
  });

  test('handles a full workflow', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    // Add three todos
    const input = screen.getByLabelText('New Todo');
    for (const task of ['Write tests', 'Review PR', 'Deploy app']) {
      await user.type(input, task);
      await user.click(screen.getByRole('button', { name: 'Add' }));
    }

    expect(screen.getByText('3 items remaining')).toBeInTheDocument();

    // Complete one
    const checkboxes = screen.getAllByRole('checkbox');
    await user.click(checkboxes[0]);

    expect(screen.getByText('2 items remaining')).toBeInTheDocument();

    // Delete another
    await user.click(screen.getByRole('button', { name: 'Delete Review PR' }));

    expect(screen.queryByText('Review PR')).not.toBeInTheDocument();
    expect(screen.getByText('1 items remaining')).toBeInTheDocument();
  });
});
flowchart TB
    subgraph TodoTests["Todo List Test Coverage"]
        E["Empty state"]
        ADD["Add todo"]
        EMPTY["Reject empty input"]
        MULTI["Multiple todos"]
        TOGGLE["Toggle completion"]
        DEL["Delete todo"]
        FLOW["Full workflow"]
    end
    E --> ADD --> MULTI
    ADD --> EMPTY
    MULTI --> TOGGLE
    MULTI --> DEL
    TOGGLE --> FLOW
    DEL --> FLOW
    style TodoTests fill:#3b82f6,color:#fff

Summary

Concept Description
React Testing Library Tests components from the user's perspective
render Renders a component into a virtual DOM
screen Provides queries to find elements
getBy Finds an element (throws if missing)
queryBy Finds an element (returns null if missing)
findBy Waits for an element to appear (async)
fireEvent Dispatches single DOM events
userEvent Simulates realistic user interactions
renderHook Tests custom hooks in isolation
act Wraps state updates in hook tests

Key Takeaways

  1. Test behavior, not implementation β€” query by role and text, not by class names or component internals
  2. Use userEvent over fireEvent for more realistic interaction simulation
  3. Choose the right query variant: getBy for present elements, queryBy for absent ones, findBy for async
  4. Wrap hook state updates in act() when using renderHook
  5. Structure tests around user workflows for maximum confidence

Exercises

Exercise 1: Basics

Create a Toggle component that switches between "ON" and "OFF" when a button is clicked. Write tests verifying the initial state and toggling behavior.

function Toggle() {
  // Implement: show "OFF" by default, toggle to "ON" on click
}

Exercise 2: Form Testing

Build a SignupForm component with fields for name, email, and password. Write tests that:

  • Submit successfully with valid data
  • Show an error when name is empty
  • Show an error when password is less than 6 characters

Exercise 3: Async Component

Create a UserProfile component that fetches user data from /api/users/:id and displays the name and email. Write tests using findBy queries and mocked fetch.

Challenge

Write a custom hook useLocalStorage(key, initialValue) that persists state to localStorage. Test it with renderHook, verifying:

  • It returns the initial value when localStorage is empty
  • It reads an existing value from localStorage
  • It updates localStorage when the value changes

References


Next up: In Day 7, we'll explore Snapshot Testing β€” how to capture and compare component output automatically, when to use snapshots vs explicit assertions, and best practices for keeping snapshots maintainable!