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
| Method | Returns | Throws | Async | Use Case |
|---|---|---|---|---|
getBy | Element | Yes | No | Element must exist |
queryBy | Element or null | No | No | Assert absence |
findBy | Promise | Yes | Yes | Wait 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
- Accessible queries (best):
getByRole,getByLabelText - Semantic queries:
getByAltText,getByTitle - 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:
- Create test QueryClient with
retry: false - Mock at API level, not React Query level
- Use
findByfor async data - 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.