You are viewing a preview of this lesson. Sign in to start learning
Back to React

Component Testing

Test React components with modern testing libraries

Component Testing in React

Master component testing in React with free flashcards and hands-on examples that reinforce your learning through spaced repetition. This lesson covers testing best practices, React Testing Library fundamentals, user-centric testing approaches, and mocking strategiesβ€”essential skills for building reliable React applications.

Welcome to Component Testing πŸ’»

Component testing is the backbone of confident React development. Unlike unit tests that focus on isolated functions, component tests verify how your UI behaves from a user's perspective. You'll render components, simulate user interactions, and assert that the DOM updates correctlyβ€”all without opening a browser.

Modern React testing has shifted from implementation-focused approaches (like Enzyme) to user-centric testing with React Testing Library. The philosophy is simple: test what users see and do, not internal component state or implementation details. This makes tests more maintainable and aligned with how your application actually works.

Core Concepts πŸ§ͺ

What is Component Testing?

Component testing validates individual React components in isolation or with minimal integration. Think of it as testing a single puzzle piece thoroughly before assembling the full picture. You verify:

  • Rendering: Does the component display the correct content?
  • User interactions: Do clicks, inputs, and events work properly?
  • State changes: Does the UI update when data changes?
  • Edge cases: How does it handle errors, empty data, or unusual inputs?
Test Type Scope Speed Confidence
Unit Single function ⚑ Fastest πŸ”΅ Low
Component UI component ⚑⚑ Fast 🟒 Medium
Integration Multiple components ⚑⚑⚑ Moderate 🟒🟒 High
End-to-End Full application 🐌 Slow 🟒🟒🟒 Highest

React Testing Library Philosophy 🎯

React Testing Library (RTL) encourages testing components the way users interact with them:

βœ… DO:

  • Query by accessible labels, text, and roles
  • Simulate real user events (click, type, submit)
  • Assert visible behavior

❌ DON'T:

  • Access component state directly
  • Test implementation details (class names, internal methods)
  • Rely on component structure (wrapper divs)

πŸ’‘ Key Principle: "The more your tests resemble the way your software is used, the more confidence they can give you." – Kent C. Dodds

Setting Up Your Test Environment πŸ”§

Most React projects created with Create React App or Vite come pre-configured with:

  • Jest or Vitest: Test runner and assertion library
  • React Testing Library: Component rendering and queries
  • jsdom: Simulates browser environment in Node.js
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      Testing Stack Architecture     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                     β”‚
β”‚  πŸ“ Your Test Code                  β”‚
β”‚         ↓                           β”‚
β”‚  πŸ§ͺ React Testing Library           β”‚
β”‚         ↓                           β”‚
β”‚  🎭 @testing-library/user-event     β”‚
β”‚         ↓                           β”‚
β”‚  πŸƒ Jest/Vitest (Test Runner)       β”‚
β”‚         ↓                           β”‚
β”‚  🌐 jsdom (Fake Browser)            β”‚
β”‚                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Arrange-Act-Assert Pattern πŸ“‹

Every component test follows this three-step structure:

  1. Arrange: Render the component with necessary props
  2. Act: Simulate user interactions or trigger events
  3. Assert: Verify the expected outcome
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('increments counter on button click', async () => {
  // ARRANGE: Set up the component
  render(<Counter initialCount={0} />);
  const user = userEvent.setup();
  
  // ACT: Simulate user clicking the button
  const button = screen.getByRole('button', { name: /increment/i });
  await user.click(button);
  
  // ASSERT: Verify the UI updated
  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

🧠 Memory Device - AAA: Think of a AAA battery powering your test: Arrange the setup, Act on the component, Assert the results.

Query Methods: Finding Elements πŸ”

React Testing Library provides powerful query methods to find elements in your rendered components. Choose the right query based on priority:

πŸ“Š Query Priority (Most Accessible β†’ Least Accessible)

Priority Query Use Case
πŸ₯‡ 1st getByRole Buttons, links, headings (accessibility!)
πŸ₯ˆ 2nd getByLabelText Form inputs with labels
πŸ₯‰ 3rd getByPlaceholderText Inputs with placeholder text
4th getByText Non-interactive text content
5th getByDisplayValue Form inputs with current values
⚠️ Avoid getByTestId Last resort when nothing else works

Query Variants: Get, Query, Find

Each query comes in three flavors:

Variant Returns Timing Use When
getBy... Element or throws Synchronous Element should exist immediately
queryBy... Element or null Synchronous Asserting element does NOT exist
findBy... Promise Asynchronous (waits) Element appears after async operation
getAllBy... Array or throws Synchronous Multiple matching elements
// βœ… Element exists now
const heading = screen.getByRole('heading', { name: /welcome/i });

// βœ… Element might not exist (for negative assertions)
const error = screen.queryByRole('alert');
expect(error).not.toBeInTheDocument();

// βœ… Element appears after loading
const data = await screen.findByText(/user data/i);

// βœ… Multiple items in a list
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);

πŸ’‘ Pro Tip: Use screen.debug() to print the current DOM structure when queries aren't finding elements.

Common Roles for getByRole 🎭

HTML elements have implicit ARIA roles that make them accessible:

Element Role Example Query
<button> button getByRole('button', { name: 'Submit' })
<a> link getByRole('link', { name: /home/i })
<h1>-<h6> heading getByRole('heading', { level: 1 })
<input type="text"> textbox getByRole('textbox', { name: 'Email' })
<input type="checkbox"> checkbox getByRole('checkbox', { name: 'Agree' })
<img> img getByRole('img', { name: 'Logo' })

Testing User Interactions ⚑

The @testing-library/user-event library simulates realistic user behavior, including focus, hover, and keyboard navigation.

Why user-event Over fireEvent?

  • fireEvent: Dispatches a single DOM event (low-level)
  • user-event: Simulates the full sequence of events a real user triggers (high-level)
import userEvent from '@testing-library/user-event';

// ❌ fireEvent: Only dispatches 'change' event
fireEvent.change(input, { target: { value: 'hello' } });

// βœ… user-event: Simulates focus, keydown, keypress, input, keyup for each character
const user = userEvent.setup();
await user.type(input, 'hello');

Common Interaction Patterns πŸ–±οΈ

Clicking Elements:

test('submits form on button click', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);
  const user = userEvent.setup();
  
  const submitButton = screen.getByRole('button', { name: /log in/i });
  await user.click(submitButton);
  
  expect(handleSubmit).toHaveBeenCalledTimes(1);
});

Typing in Inputs:

test('updates search results as user types', async () => {
  render(<SearchBar />);
  const user = userEvent.setup();
  
  const input = screen.getByRole('textbox', { name: /search/i });
  await user.type(input, 'React Testing');
  
  expect(input).toHaveValue('React Testing');
  expect(await screen.findByText(/3 results/i)).toBeInTheDocument();
});

Selecting Options:

test('filters list by category selection', async () => {
  render(<ProductList />);
  const user = userEvent.setup();
  
  const select = screen.getByRole('combobox', { name: /category/i });
  await user.selectOptions(select, 'Electronics');
  
  const items = screen.getAllByRole('listitem');
  expect(items).toHaveLength(5);
});

Checking/Unchecking:

test('toggles newsletter subscription', async () => {
  render(<NewsletterForm />);
  const user = userEvent.setup();
  
  const checkbox = screen.getByRole('checkbox', { name: /subscribe/i });
  
  await user.click(checkbox);
  expect(checkbox).toBeChecked();
  
  await user.click(checkbox);
  expect(checkbox).not.toBeChecked();
});

Testing Asynchronous Behavior ⏳

Many React components fetch data, wait for timeouts, or update asynchronously. React Testing Library provides tools to handle these scenarios.

Using findBy Queries

findBy queries return a Promise that resolves when the element appears:

test('displays user data after loading', async () => {
  render(<UserProfile userId={123} />);
  
  // Initially shows loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
  
  // Wait for data to appear (default timeout: 1000ms)
  const username = await screen.findByText(/john doe/i);
  expect(username).toBeInTheDocument();
  
  // Loading indicator should be gone
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

Using waitFor for Complex Assertions

waitFor retries until the callback doesn't throw or times out:

import { waitFor } from '@testing-library/react';

test('validates form after debounced input', async () => {
  render(<RegistrationForm />);
  const user = userEvent.setup();
  
  const emailInput = screen.getByLabelText(/email/i);
  await user.type(emailInput, 'invalid-email');
  
  // Wait for debounced validation (500ms)
  await waitFor(() => {
    expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
  }, { timeout: 2000 });
});

Testing with Mock APIs πŸ”Œ

Use Mock Service Worker (MSW) or simple mocks to simulate API responses:

import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' }
      ])
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('displays fetched users', async () => {
  render(<UserList />);
  
  const users = await screen.findAllByRole('listitem');
  expect(users).toHaveLength(2);
  expect(screen.getByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('Bob')).toBeInTheDocument();
});

Example 1: Testing a Simple Counter Component πŸ”’

Let's test a basic counter with increment, decrement, and reset functionality:

Counter.jsx:

import { useState } from 'react';

function Counter({ initialCount = 0, step = 1 }) {
  const [count, setCount] = useState(initialCount);
  
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + step)}>Increment</button>
      <button onClick={() => setCount(count - step)}>Decrement</button>
      <button onClick={() => setCount(initialCount)}>Reset</button>
    </div>
  );
}

export default Counter;

Counter.test.jsx:

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

describe('Counter Component', () => {
  test('renders with initial count', () => {
    render(<Counter initialCount={5} />);
    expect(screen.getByText('Count: 5')).toBeInTheDocument();
  });
  
  test('increments count when increment button clicked', async () => {
    render(<Counter initialCount={0} step={1} />);
    const user = userEvent.setup();
    
    const incrementBtn = screen.getByRole('button', { name: /increment/i });
    await user.click(incrementBtn);
    
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
  
  test('decrements count when decrement button clicked', async () => {
    render(<Counter initialCount={10} step={2} />);
    const user = userEvent.setup();
    
    const decrementBtn = screen.getByRole('button', { name: /decrement/i });
    await user.click(decrementBtn);
    
    expect(screen.getByText('Count: 8')).toBeInTheDocument();
  });
  
  test('resets count to initial value', async () => {
    render(<Counter initialCount={5} />);
    const user = userEvent.setup();
    
    // Change the count first
    await user.click(screen.getByRole('button', { name: /increment/i }));
    expect(screen.getByText('Count: 6')).toBeInTheDocument();
    
    // Then reset
    await user.click(screen.getByRole('button', { name: /reset/i }));
    expect(screen.getByText('Count: 5')).toBeInTheDocument();
  });
  
  test('handles multiple rapid clicks', async () => {
    render(<Counter />);
    const user = userEvent.setup();
    
    const incrementBtn = screen.getByRole('button', { name: /increment/i });
    
    await user.click(incrementBtn);
    await user.click(incrementBtn);
    await user.click(incrementBtn);
    
    expect(screen.getByText('Count: 3')).toBeInTheDocument();
  });
});

Key Takeaways:

  • βœ… Each test is independent and doesn't rely on others
  • βœ… We test behavior (clicks change the display) not implementation
  • βœ… Props are tested to ensure configurability works
  • βœ… userEvent.setup() is called at the start of each test

Example 2: Testing a Search Filter Component πŸ”

This example demonstrates testing derived state and filtering logic:

ProductSearch.jsx:

import { useState } from 'react';

function ProductSearch({ products }) {
  const [query, setQuery] = useState('');
  
  const filteredProducts = products.filter(product =>
    product.name.toLowerCase().includes(query.toLowerCase())
  );
  
  return (
    <div>
      <label htmlFor="search">Search Products:</label>
      <input
        id="search"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Type to search..."
      />
      <p>{filteredProducts.length} results found</p>
      <ul>
        {filteredProducts.map(product => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default ProductSearch;

ProductSearch.test.jsx:

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

const mockProducts = [
  { id: 1, name: 'Laptop' },
  { id: 2, name: 'Keyboard' },
  { id: 3, name: 'Mouse' },
  { id: 4, name: 'Monitor' }
];

describe('ProductSearch Component', () => {
  test('displays all products initially', () => {
    render(<ProductSearch products={mockProducts} />);
    
    expect(screen.getByText('4 results found')).toBeInTheDocument();
    expect(screen.getByText('Laptop')).toBeInTheDocument();
    expect(screen.getByText('Monitor')).toBeInTheDocument();
  });
  
  test('filters products based on search query', async () => {
    render(<ProductSearch products={mockProducts} />);
    const user = userEvent.setup();
    
    const searchInput = screen.getByLabelText(/search products/i);
    await user.type(searchInput, 'Key');
    
    expect(screen.getByText('1 results found')).toBeInTheDocument();
    expect(screen.getByText('Keyboard')).toBeInTheDocument();
    expect(screen.queryByText('Laptop')).not.toBeInTheDocument();
  });
  
  test('search is case insensitive', async () => {
    render(<ProductSearch products={mockProducts} />);
    const user = userEvent.setup();
    
    const searchInput = screen.getByLabelText(/search products/i);
    await user.type(searchInput, 'MOUSE');
    
    expect(screen.getByText('1 results found')).toBeInTheDocument();
    expect(screen.getByText('Mouse')).toBeInTheDocument();
  });
  
  test('shows no results message when no matches', async () => {
    render(<ProductSearch products={mockProducts} />);
    const user = userEvent.setup();
    
    const searchInput = screen.getByLabelText(/search products/i);
    await user.type(searchInput, 'Tablet');
    
    expect(screen.getByText('0 results found')).toBeInTheDocument();
    expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
  });
  
  test('handles empty products array gracefully', () => {
    render(<ProductSearch products={[]} />);
    
    expect(screen.getByText('0 results found')).toBeInTheDocument();
  });
});

Key Takeaways:

  • βœ… We use queryBy for negative assertions (element should NOT exist)
  • βœ… Mock data is defined outside tests for reusability
  • βœ… Edge cases (empty list, no matches) are tested
  • βœ… The input is queried by its accessible label, not by placeholder

Example 3: Testing Asynchronous Data Fetching πŸ“‘

Here's a component that fetches user data and handles loading/error states:

UserProfile.jsx:

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <div>Loading user data...</div>;
  if (error) return <div role="alert">Error: {error}</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
    </div>
  );
}

export default UserProfile;

UserProfile.test.jsx:

import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock the global fetch function
global.fetch = jest.fn();

describe('UserProfile Component', () => {
  beforeEach(() => {
    fetch.mockClear();
  });
  
  test('displays loading state initially', () => {
    fetch.mockImplementation(() => new Promise(() => {})); // Never resolves
    
    render(<UserProfile userId={1} />);
    expect(screen.getByText(/loading user data/i)).toBeInTheDocument();
  });
  
  test('displays user data after successful fetch', async () => {
    const mockUser = {
      name: 'Jane Doe',
      email: 'jane@example.com',
      role: 'Admin'
    };
    
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser
    });
    
    render(<UserProfile userId={1} />);
    
    // Wait for loading to finish
    expect(await screen.findByText('Jane Doe')).toBeInTheDocument();
    expect(screen.getByText('Email: jane@example.com')).toBeInTheDocument();
    expect(screen.getByText('Role: Admin')).toBeInTheDocument();
    
    // Loading should be gone
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });
  
  test('displays error message on fetch failure', async () => {
    fetch.mockResolvedValueOnce({
      ok: false
    });
    
    render(<UserProfile userId={999} />);
    
    const errorMsg = await screen.findByRole('alert');
    expect(errorMsg).toHaveTextContent(/failed to fetch/i);
  });
  
  test('refetches when userId prop changes', async () => {
    fetch.mockResolvedValue({
      ok: true,
      json: async () => ({ name: 'User 1', email: 'user1@test.com', role: 'User' })
    });
    
    const { rerender } = render(<UserProfile userId={1} />);
    await screen.findByText('User 1');
    
    // Change the userId prop
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ name: 'User 2', email: 'user2@test.com', role: 'Admin' })
    });
    
    rerender(<UserProfile userId={2} />);
    await screen.findByText('User 2');
    
    expect(fetch).toHaveBeenCalledTimes(2);
  });
});

Key Takeaways:

  • βœ… We mock fetch globally to avoid real network requests
  • βœ… findBy queries automatically wait for async updates
  • βœ… Each test clears mocks in beforeEach for isolation
  • βœ… We test all states: loading, success, and error
  • βœ… The rerender utility tests prop changes

Example 4: Testing Forms with Validation πŸ“

Forms are common in React apps and require testing user input, validation, and submission:

LoginForm.jsx:

import { useState } from 'react';

function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  
  const validate = () => {
    const newErrors = {};
    if (!email.includes('@')) {
      newErrors.email = 'Invalid email address';
    }
    if (password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }
    return newErrors;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const validationErrors = validate();
    
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    
    setErrors({});
    onSubmit({ email, password });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          type="text"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
        {errors.password && <span role="alert">{errors.password}</span>}
      </div>
      
      <button type="submit">Log 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 Component', () => {
  test('submits form with valid credentials', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(mockSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
    expect(mockSubmit).toHaveBeenCalledTimes(1);
  });
  
  test('shows error for invalid email', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'invalidemail');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  test('shows error for short password', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), '123');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  test('shows multiple errors simultaneously', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    const user = userEvent.setup();
    
    await user.type(screen.getByLabelText(/email/i), 'bad');
    await user.type(screen.getByLabelText(/password/i), 'abc');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
    expect(screen.getByText(/password must be/i)).toBeInTheDocument();
    expect(mockSubmit).not.toHaveBeenCalled();
  });
  
  test('clears errors after successful submit', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    const user = userEvent.setup();
    
    // First, trigger errors
    await user.type(screen.getByLabelText(/email/i), 'bad');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
    
    // Then fix and submit
    await user.clear(screen.getByLabelText(/email/i));
    await user.type(screen.getByLabelText(/email/i), 'good@test.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /log in/i }));
    
    expect(screen.queryByText(/invalid email/i)).not.toBeInTheDocument();
    expect(mockSubmit).toHaveBeenCalledTimes(1);
  });
});

Key Takeaways:

  • βœ… Mock functions (jest.fn()) verify callbacks are called correctly
  • βœ… We test both valid and invalid input scenarios
  • βœ… user.clear() empties an input before typing new content
  • βœ… Error messages use role="alert" for accessibility
  • βœ… We verify the function is NOT called when validation fails

Common Mistakes ⚠️

1. Testing Implementation Details

❌ Wrong:

test('counter state updates', () => {
  const { container } = render(<Counter />);
  const instance = container.querySelector('.counter-component');
  expect(instance.state.count).toBe(0); // Accessing internal state!
});

βœ… Right:

test('displays initial count', () => {
  render(<Counter />);
  expect(screen.getByText('Count: 0')).toBeInTheDocument(); // Test visible output
});

Why it matters: Implementation can change (class β†’ hooks, state structure refactors), but user-visible behavior should stay constant.

2. Not Using await with Async Actions

❌ Wrong:

test('button click', () => {
  render(<MyComponent />);
  const user = userEvent.setup();
  
  user.click(screen.getByRole('button')); // Missing await!
  expect(screen.getByText('Clicked')).toBeInTheDocument();
});

βœ… Right:

test('button click', async () => {
  render(<MyComponent />);
  const user = userEvent.setup();
  
  await user.click(screen.getByRole('button')); // Properly awaited
  expect(screen.getByText('Clicked')).toBeInTheDocument();
});

3. Using getBy When queryBy is Appropriate

❌ Wrong:

test('error message not shown initially', () => {
  render(<Form />);
  
  try {
    const error = screen.getByRole('alert'); // Throws if not found!
    expect(error).not.toBeInTheDocument(); // Never reached
  } catch (e) {
    // This is not a good pattern
  }
});

βœ… Right:

test('error message not shown initially', () => {
  render(<Form />);
  expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // Returns null, no throw
});

4. Not Cleaning Up Mocks

❌ Wrong:

test('fetches data', async () => {
  fetch.mockResolvedValue({ json: async () => ({ data: 'test' }) });
  // Test code...
  // Mock persists to next test!
});

test('another test', () => {
  // Still using the mock from previous test
});

βœ… Right:

beforeEach(() => {
  fetch.mockClear(); // or jest.clearAllMocks()
});

test('fetches data', async () => {
  fetch.mockResolvedValueOnce({ json: async () => ({ data: 'test' }) });
  // Test code...
});

5. Querying by Test IDs First

❌ Wrong:

test('finds submit button', () => {
  render(<Form />);
  const button = screen.getByTestId('submit-button'); // Least accessible!
});

βœ… Right:

test('finds submit button', () => {
  render(<Form />);
  const button = screen.getByRole('button', { name: /submit/i }); // Accessible!
});

6. Testing Multiple Things in One Test

❌ Wrong:

test('form works completely', async () => {
  // Tests validation, submission, error handling, reset, etc. all at once
  // 100+ lines of test code...
});

βœ… Right:

test('shows validation error for invalid email', async () => { /* ... */ });
test('submits form with valid data', async () => { /* ... */ });
test('clears form after submission', async () => { /* ... */ });

7. Not Waiting for Async Updates

❌ Wrong:

test('displays fetched data', () => {
  render(<DataComponent />);
  expect(screen.getByText('Data loaded')).toBeInTheDocument(); // Fails immediately!
});

βœ… Right:

test('displays fetched data', async () => {
  render(<DataComponent />);
  expect(await screen.findByText('Data loaded')).toBeInTheDocument(); // Waits!
});

Key Takeaways πŸŽ“

πŸ“‹ Component Testing Quick Reference

Testing Philosophy:

  • Test behavior, not implementation
  • Query by accessibility (roles, labels) first
  • Simulate real user interactions
  • Wait for async changes properly

Query Priority:

  1. getByRole β†’ Most accessible
  2. getByLabelText β†’ Form inputs
  3. getByPlaceholderText β†’ Input hints
  4. getByText β†’ Static content
  5. getByTestId β†’ Last resort

Query Variants:

  • getBy... β†’ Element exists now (throws if not)
  • queryBy... β†’ Element might not exist (returns null)
  • findBy... β†’ Element appears after async (returns Promise)
  • getAllBy... β†’ Multiple elements (returns array)

User Interactions:

const user = userEvent.setup();
await user.click(button);
await user.type(input, 'text');
await user.selectOptions(select, 'value');
await user.clear(input);

Async Testing:

// Wait for element to appear
const element = await screen.findByText(/text/i);

// Wait for complex assertions
await waitFor(() => {
  expect(condition).toBe(true);
});

Common Matchers:

  • .toBeInTheDocument() β†’ Element exists in DOM
  • .toHaveTextContent(text) β†’ Element contains text
  • .toHaveValue(value) β†’ Input has specific value
  • .toBeChecked() β†’ Checkbox/radio is checked
  • .toBeDisabled() β†’ Element is disabled
  • .toHaveAttribute(attr, value) β†’ Element has attribute

Remember AAA Pattern:

  1. Arrange: Render component with props
  2. Act: Simulate user interactions
  3. Assert: Verify expected outcomes

Best Practices Checklist:

  • βœ… Use userEvent instead of fireEvent
  • βœ… Always await user interactions
  • βœ… Query by accessible attributes (role, label)
  • βœ… Use queryBy for negative assertions
  • βœ… Use findBy for async elements
  • βœ… Mock external dependencies (APIs, timers)
  • βœ… Clean up mocks between tests
  • βœ… Test edge cases (empty data, errors, loading)
  • βœ… Keep tests focused and independent
  • βœ… Use screen.debug() when queries fail

πŸ’‘ Pro Tip: Install the Testing Library browser extension to inspect components with testing queries in your dev tools!

πŸ“š Further Study

  1. React Testing Library Documentation: https://testing-library.com/docs/react-testing-library/intro/
  2. Common Testing Mistakes: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
  3. Testing Library Queries Cheatsheet: https://testing-library.com/docs/queries/about#priority