Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React Testing: Jest and React Testing Library

March 17, 2025 · 8 min read

React, Testing, Jest, React Testing Library, Interview Prep

Introduction

Testing is essential for maintainable React applications. This article covers testing with Jest and React Testing Library, focusing on user-centric patterns, mocking strategies, and testing complex scenarios like async operations.


Testing Philosophy

React Testing Library encourages testing how users interact with your app, not implementation details.

// ❌ Testing implementation details
test('sets isLoading state to true when fetching', () => {
  const { result } = renderHook(() => useProducts());
  expect(result.current.isLoading).toBe(true);
});

// ✅ Testing user experience
test('shows loading spinner while products are being fetched', async () => {
  render(<ProductList />);
  expect(screen.getByRole('progressbar')).toBeInTheDocument();
  await waitForElementToBeRemoved(() => screen.queryByRole('progressbar'));
});

The Guiding Principle

"The more your tests resemble the way your software is used, the more confidence they can give you." - Testing Library docs

Testing Lessons Learned

I learned this the hard way on Glucoplate. Tests checking internal state broke constantly during refactors. After switching to user-centric tests ("shows spinner while loading", "displays results after search"), tests became stable and caught real bugs. Test what users see, not implementation details.


Basic Component Testing

// ProductCard.jsx
export function ProductCard({ product, onAddToCart }) {
  return (
    <article className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price} USD</p>
      <button onClick={() => onAddToCart(product.id)}>
        Add to Cart
      </button>
    </article>
  );
}

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

const mockProduct = {
  id: '1',
  name: 'Wireless Headphones',
  price: 99.99,
};

describe('ProductCard', () => {
  const mockOnAddToCart = vi.fn();

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders product information', () => {
    render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);

    expect(screen.getByRole('heading', { name: 'Wireless Headphones' })).toBeInTheDocument();
    expect(screen.getByText('99.99 USD')).toBeInTheDocument();
  });

  it('calls onAddToCart when button is clicked', async () => {
    const user = userEvent.setup();
    render(<ProductCard product={mockProduct} onAddToCart={mockOnAddToCart} />);

    await user.click(screen.getByRole('button', { name: /add to cart/i }));

    expect(mockOnAddToCart).toHaveBeenCalledTimes(1);
    expect(mockOnAddToCart).toHaveBeenCalledWith('1');
  });
});

Query Methods

MethodReturnsThrowsAsyncUse Case
getByElementYesNoElement must exist
queryByElement or nullNoNoAssert absence
findByPromiseYesYesWait for element
// getBy - throws if not found
screen.getByRole('button');

// queryBy - returns null if not found
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

// findBy - waits for element to appear
await screen.findByText('Data loaded');

Query Priority

  1. Accessible queries (best): getByRole, getByLabelText
  2. Semantic queries: getByAltText, getByTitle
  3. Test IDs (last resort): getByTestId
// ✅ Preferred - tests accessibility too
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email/i);

// ❌ Avoid - doesn't test accessibility
screen.getByTestId('submit-button');

Testing Forms

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

describe('LoginForm', () => {
  const mockOnSubmit = vi.fn();

  it('submits form with valid data', async () => {
    const user = userEvent.setup();
    mockOnSubmit.mockResolvedValue(undefined);

    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'password123',
      });
    });
  });

  it('shows validation error for invalid email', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'invalid');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/invalid email/i);
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('disables button while submitting', async () => {
    const user = userEvent.setup();
    mockOnSubmit.mockImplementation(() => new Promise(r => setTimeout(r, 100)));

    render(<LoginForm onSubmit={mockOnSubmit} />);

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'password123');
    await user.click(screen.getByRole('button', { name: /log in/i }));

    expect(screen.getByRole('button')).toBeDisabled();

    await waitFor(() => {
      expect(screen.getByRole('button')).not.toBeDisabled();
    });
  });
});

Testing Async Operations

// ProductList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProductList } from './ProductList';
import * as api from '../api/products';

vi.mock('../api/products');

const mockProducts = [
  { id: '1', name: 'Product A', price: 29.99 },
  { id: '2', name: 'Product B', price: 49.99 },
];

function renderWithProviders(ui) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

describe('ProductList', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('shows loading state initially', () => {
    vi.mocked(api.fetchProducts).mockImplementation(() => new Promise(() => {}));

    renderWithProviders(<ProductList />);

    expect(screen.getByRole('progressbar')).toBeInTheDocument();
  });

  it('displays products after loading', async () => {
    vi.mocked(api.fetchProducts).mockResolvedValue(mockProducts);

    renderWithProviders(<ProductList />);

    expect(await screen.findByText('Product A')).toBeInTheDocument();
    expect(screen.getByText('Product B')).toBeInTheDocument();
  });

  it('shows error message when fetch fails', async () => {
    vi.mocked(api.fetchProducts).mockRejectedValue(new Error('Network error'));

    renderWithProviders(<ProductList />);

    expect(await screen.findByRole('alert')).toHaveTextContent('Network error');
  });

  it('shows empty state when no products', async () => {
    vi.mocked(api.fetchProducts).mockResolvedValue([]);

    renderWithProviders(<ProductList />);

    expect(await screen.findByText(/no products found/i)).toBeInTheDocument();
  });
});

Testing with Providers

// test/renderWithProviders.jsx
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { AuthContext } from '../contexts/AuthContext';

export function renderWithProviders(ui, options = {}) {
  const {
    authState = {},
    route = '/',
    ...renderOptions
  } = options;

  window.history.pushState({}, '', route);

  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
    },
  });

  const defaultAuthState = {
    user: null,
    isAuthenticated: false,
    isLoading: false,
    login: vi.fn(),
    logout: vi.fn(),
    ...authState,
  };

  function Wrapper({ children }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthContext.Provider value={defaultAuthState}>
          <BrowserRouter>
            {children}
          </BrowserRouter>
        </AuthContext.Provider>
      </QueryClientProvider>
    );
  }

  return render(ui, { wrapper: Wrapper, ...renderOptions });
}

// Usage
it('shows user name when authenticated', () => {
  renderWithProviders(<UserProfile />, {
    authState: {
      isAuthenticated: true,
      user: { id: '1', name: 'John Doe' },
    },
  });

  expect(screen.getByText('John Doe')).toBeInTheDocument();
});

Testing Custom Hooks

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

describe('useCounter', () => {
  it('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increments counter', () => {
    const { result } = renderHook(() => useCounter(0));

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

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

  it('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5));

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

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

  it('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);
  });
});

Mocking Strategies

Module Mocks

vi.mock('../api/products', () => ({
  fetchProducts: vi.fn(),
  createProduct: vi.fn(),
}));

import { fetchProducts } from '../api/products';

beforeEach(() => {
  vi.mocked(fetchProducts).mockResolvedValue(mockProducts);
});

Timer Mocks

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

it('shows toast for 3 seconds', () => {
  render(<Toast message="Saved!" />);

  expect(screen.getByText('Saved!')).toBeInTheDocument();

  act(() => {
    vi.advanceTimersByTime(3000);
  });

  expect(screen.queryByText('Saved!')).not.toBeInTheDocument();
});

Fetch Mocks

beforeEach(() => {
  global.fetch = vi.fn();
});

it('fetches data', async () => {
  vi.mocked(fetch).mockResolvedValueOnce({
    ok: true,
    json: async () => mockData,
  });

  render(<DataComponent />);

  await waitFor(() => {
    expect(fetch).toHaveBeenCalledWith('/api/data');
  });
});

Interview Questions

Q1: getBy vs queryBy vs findBy?

Answer:

  • getBy - Synchronous, throws if not found. Use for elements that must exist.
  • queryBy - Synchronous, returns null if not found. Use for asserting absence.
  • findBy - Async, waits up to timeout. Use for elements that appear after async operations.

Q2: Why use userEvent instead of fireEvent?

Answer: userEvent simulates real browser behavior:

  • Triggers all related events (focus, blur, change)
  • Handles keyboard navigation
  • Tests accessibility properly
  • More realistic than low-level event firing
// fireEvent - low level
fireEvent.change(input, { target: { value: 'text' } });

// userEvent - realistic
await user.type(input, 'text');

Q3: How do you test components with React Query?

Answer:

  1. Create test QueryClient with retry: false
  2. Mock at API level, not React Query level
  3. Use findBy for async data
  4. Create fresh QueryClient per test

Q4: What's the purpose of act()?

Answer: act() ensures state updates are processed before assertions. RTL's render, userEvent already wrap in act(). Use it manually for:

  • Hook testing with renderHook
  • Timer-based updates
  • Manual promise resolution

Common Mistakes

1. Testing Implementation Details

// ❌ Testing internal state
expect(result.current.isLoading).toBe(true);

// ✅ Testing what users see
expect(screen.getByRole('progressbar')).toBeInTheDocument();

2. Not Waiting for Async Updates

// ❌ Assertion runs before update
render(<AsyncComponent />);
expect(screen.getByText('Data')).toBeInTheDocument(); // Fails!

// ✅ Wait for element
expect(await screen.findByText('Data')).toBeInTheDocument();

3. Forgetting await with userEvent

// ❌ Missing await
user.click(button);
expect(callback).toHaveBeenCalled(); // May fail!

// ✅ Await the interaction
await user.click(button);
expect(callback).toHaveBeenCalled();

Summary

  • Test user behavior, not implementation details
  • Use userEvent for realistic interactions
  • Use findBy for async elements, queryBy for absence
  • Mock at network boundaries (APIs), not internal modules
  • Create fresh providers per test to prevent leakage
  • Use waitFor for assertions on async updates

Next up: TypeScript with React - typing props, hooks, and advanced patterns.


Part 9 of the React Developer Reference series.

← Previous

TypeScript with React: The Complete Guide

Next →

React Performance: Memoization and Code Splitting