
After adopting TypeScript at Dentira, we focused on improving our test coverage. Writing comprehensive tests with Jest and React Testing Library (RTL) helped us catch bugs early and refactor with confidence. Here's what I've learned about testing React components effectively.
Before diving into implementation, it's important to understand why testing matters:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
};
// src/setupTests.ts
import '@testing-library/jest-dom';
React Testing Library follows the principle: Test behavior, not implementation.
Focus on what users see and interact with, not internal component details.
import { render, screen } from '@testing-library/react';
import { ProductCard } from './ProductCard';
test('renders product name', () => {
const product = { id: '1', name: 'Test Product', price: 99.99 };
render(<ProductCard product={product} />);
expect(screen.getByText('Test Product')).toBeInTheDocument();
});
import userEvent from '@testing-library/user-event';
test('calls onAddToCart when button is clicked', async () => {
const onAddToCart = jest.fn();
const product = { id: '1', name: 'Test Product' };
render(<ProductCard product={product} onAddToCart={onAddToCart} />);
const button = screen.getByRole('button', { name: /add to cart/i });
await userEvent.click(button);
expect(onAddToCart).toHaveBeenCalledWith('1');
});
test('displays loading state', async () => {
render(<ProductList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
Prefer queries that mirror how users interact:
// Good
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);
// Avoid if possible
screen.getByTestId('submit-button');
Test complete user interactions, not isolated functions:
test('user can add item to cart', async () => {
const user = userEvent.setup();
render(<ShoppingFlow />);
// Find and click product
await user.click(screen.getByText('Product Name'));
// Add to cart
await user.click(screen.getByRole('button', { name: /add to cart/i }));
// Verify cart updates
expect(screen.getByText(/cart \(1\)/i)).toBeInTheDocument();
});
Mock API calls and external services:
jest.mock('../services/api', () => ({
fetchProducts: jest.fn(() => Promise.resolve([
{ id: '1', name: 'Product 1' }
]))
}));
Ensure tests don't affect each other:
afterEach(() => {
cleanup();
});
test('submits form with valid data', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
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: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
test('shows error message when API fails', async () => {
jest.spyOn(api, 'fetchData').mockRejectedValue(new Error('API Error'));
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText(/error loading data/i)).toBeInTheDocument();
});
});
At Dentira, we aim for:
// package.json
{
"scripts": {
"test:coverage": "jest --coverage"
}
}
Use screen.debug() to see what's rendered:
test('debug rendering', () => {
render(<MyComponent />);
screen.debug(); // Prints the rendered HTML
});
At Dentira, implementing comprehensive testing:
Testing is an investment that pays dividends over time. Start with critical paths and gradually expand coverage. The key is consistency and making testing part of your development workflow.