In this comprehensive lesson, you'll master testing strategies for React applications that ensure code quality, prevent regressions, and provide confidence in your codebase. You'll learn testing fundamentals, best practices, and how to write effective tests that are maintainable and valuable. From unit tests to integration tests, you'll build a solid testing foundation for professional React development.
Testing is essential for building reliable, maintainable applications:
Quality Assurance: Catches bugs before they reach production Refactoring Confidence: Enables safe code changes and improvements Documentation: Tests serve as living documentation of component behavior Team Collaboration: Provides clear specifications for component functionality Regression Prevention: Ensures new changes don't break existing functionality
Testing Philosophy: Tests should be fast, reliable, and maintainable. Focus on testing behavior, not implementation details.
Understanding the different levels of testing:
Unit Tests (70%): Test individual functions and components in isolation Integration Tests (20%): Test how multiple components work together End-to-End Tests (10%): Test complete user workflows
Benefits of Pyramid Structure:
Write Tests That:
Avoid Testing:
Jest is a JavaScript testing framework that provides a complete testing solution:
Features: Test runner, assertion library, mocking utilities, code coverage Benefits: Zero configuration, fast execution, great documentation Integration: Works seamlessly with React and React Testing Library
// Basic test structure
describe('Calculator', () => {
test('adds two numbers correctly', () => {
const result = add(2, 3)
expect(result).toBe(5)
})
test('handles negative numbers', () => {
const result = add(-2, 3)
expect(result).toBe(1)
})
})
// Async testing
test('fetches user data', async () => {
const user = await fetchUser(1)
expect(user).toHaveProperty('name')
expect(user.name).toBe('John Doe')
})
// Error testing
test('throws error for invalid input', () => {
expect(() => divide(10, 0)).toThrow('Division by zero')
})
// Common matchers
expect(value).toBe(expected) // Strict equality
expect(value).toEqual(expected) // Deep equality
expect(value).toBeTruthy() // Truthy value
expect(value).toBeFalsy() // Falsy value
expect(value).toBeNull() // Null value
expect(value).toBeUndefined() // Undefined value
// String matchers
expect(string).toContain(substring) // Contains substring
expect(string).toMatch(pattern) // Matches regex
expect(string).toHaveLength(length) // String length
// Array matchers
expect(array).toContain(item) // Contains item
expect(array).toHaveLength(length) // Array length
expect(array).toEqual(expectedArray) // Array equality
// Object matchers
expect(object).toHaveProperty(key) // Has property
expect(object).toMatchObject(expected) // Partial match
React Testing Library provides utilities to test React components from a user's perspective:
Philosophy: Test components as users would interact with them Benefits: Focus on behavior, accessible queries, user-centric testing Key Principle: "The more your tests resemble the way your software is used, the more confidence they can give you."
import { render, screen, fireEvent, userEvent } from '@testing-library/react'
import { Button } from './Button'
// Basic rendering test
test('renders button with text', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
})
// Interaction testing
test('calls onClick when clicked', async () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
await userEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
// Props testing
test('applies correct className', () => {
render(<Button variant="primary">Button</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('btn-primary')
})
// Form component testing
test('submits form with valid data', async () => {
const handleSubmit = jest.fn()
render(<ContactForm onSubmit={handleSubmit} />)
// Fill form fields
await userEvent.type(screen.getByLabelText(/name/i), 'John Doe')
await userEvent.type(screen.getByLabelText(/email/i), '[email protected]')
await userEvent.type(screen.getByLabelText(/message/i), 'Hello world')
// Submit form
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
// Assert submission
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: '[email protected]',
message: 'Hello world'
})
})
// Async component testing
test('displays loading state then data', async () => {
render(<UserProfile userId="1" />)
// Initial loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for data to load
await waitFor(() => {
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
})
// Loading state should be gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
// Conditional rendering
test('shows error message when form is invalid', async () => {
render(<ContactForm />)
// Submit empty form
await userEvent.click(screen.getByRole('button', { name: /submit/i }))
// Check for error messages
expect(screen.getByText(/name is required/i)).toBeInTheDocument()
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
})
// Component lifecycle
test('calls cleanup on unmount', () => {
const cleanup = jest.fn()
const { unmount } = render(<ComponentWithCleanup onCleanup={cleanup} />)
unmount()
expect(cleanup).toHaveBeenCalled()
})
// Event handling
test('handles keyboard events', async () => {
const handleKeyPress = jest.fn()
render(<SearchInput onKeyPress={handleKeyPress} />)
const input = screen.getByRole('textbox')
await userEvent.type(input, 'test')
expect(handleKeyPress).toHaveBeenCalledTimes(4)
})
Mocking replaces real dependencies with test doubles:
Purpose: Isolate code under test, control external dependencies, improve test performance Types: Mocks, stubs, spies, fakes Best Practice: Mock at boundaries (API calls, external libraries)
// Mocking functions
const mockFetch = jest.fn()
global.fetch = mockFetch
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: 'test' })
})
// Mocking modules
jest.mock('./api', () => ({
fetchUser: jest.fn(() => Promise.resolve({ id: 1, name: 'John' }))
}))
// Mocking React hooks
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => jest.fn()
}))
// Mock API responses
beforeEach(() => {
jest.clearAllMocks()
})
test('loads and displays user data', async () => {
const mockUser = { id: 1, name: 'John Doe', email: '[email protected]' }
// Mock the API call
jest.spyOn(api, 'fetchUser').mockResolvedValue(mockUser)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
})
expect(api.fetchUser).toHaveBeenCalledWith('1')
})
test('handles API error gracefully', async () => {
// Mock API error
jest.spyOn(api, 'fetchUser').mockRejectedValue(new Error('User not found'))
render(<UserProfile userId="999" />)
await waitFor(() => {
expect(screen.getByText(/error loading user/i)).toBeInTheDocument()
})
})
Integration tests verify that multiple components work together correctly:
Scope: Component interactions, data flow, user workflows Benefits: Catches integration issues, tests realistic scenarios Tools: React Testing Library, custom test utilities
// Testing parent-child component interaction
test('parent component updates when child changes', async () => {
const handleDataChange = jest.fn()
render(
<ParentComponent onDataChange={handleDataChange}>
<ChildComponent />
</ParentComponent>
)
// Interact with child
await userEvent.click(screen.getByText('Update Data'))
// Verify parent received update
expect(handleDataChange).toHaveBeenCalledWith('new-data')
})
// Testing form with multiple components
test('multi-step form completes successfully', async () => {
render(<MultiStepForm />)
// Step 1
await userEvent.type(screen.getByLabelText(/name/i), 'John Doe')
await userEvent.click(screen.getByText('Next'))
// Step 2
await userEvent.type(screen.getByLabelText(/email/i), '[email protected]')
await userEvent.click(screen.getByText('Submit'))
// Success state
await waitFor(() => {
expect(screen.getByText(/form submitted successfully/i)).toBeInTheDocument()
})
})
// Custom render function with providers
function renderWithProviders(ui, { providerProps = {} } = {}) {
function Wrapper({ children }) {
return (
<AuthProvider {...providerProps}>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
)
}
return render(ui, { wrapper: Wrapper })
}
// Test with context
test('displays user name when authenticated', () => {
const mockUser = { name: 'John Doe' }
renderWithProviders(<UserProfile />, {
providerProps: { value: { user: mockUser } }
})
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
TDD is a development process where tests are written before code:
Red-Green-Refactor Cycle:
Benefits: Better design, comprehensive test coverage, confidence in refactoring
// Step 1: Write failing test (Red)
test('calculates total price with tax', () => {
const cart = new ShoppingCart()
cart.addItem({ price: 100, quantity: 2 })
expect(cart.getTotalWithTax(0.1)).toBe(220)
})
// Step 2: Make test pass (Green)
class ShoppingCart {
constructor() {
this.items = []
}
addItem(item) {
this.items.push(item)
}
getTotalWithTax(taxRate) {
const subtotal = this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
)
return subtotal * (1 + taxRate)
}
}
// Step 3: Refactor while keeping tests green
class ShoppingCart {
constructor() {
this.items = []
}
addItem(item) {
this.items.push(item)
}
getSubtotal() {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
)
}
getTotalWithTax(taxRate) {
return this.getSubtotal() * (1 + taxRate)
}
}
// Describe blocks for organization
describe('UserProfile', () => {
describe('Rendering', () => {
test('displays user name', () => {})
test('displays user avatar', () => {})
test('shows loading state', () => {})
})
describe('Interactions', () => {
test('opens edit modal on edit click', () => {})
test('saves changes on form submit', () => {})
})
describe('Error Handling', () => {
test('displays error message on API failure', () => {})
test('handles missing user data', () => {})
})
})
// Custom render utilities
const customRender = (ui, options = {}) => {
const defaultProps = {
theme: 'light',
user: null,
...options
}
return render(
<ThemeProvider theme={defaultProps.theme}>
<AuthProvider user={defaultProps.user}>
{ui}
</AuthProvider>
</ThemeProvider>
)
}
// Custom matchers
expect.extend({
toBeInTheDocument: (received) => {
const pass = received && document.body.contains(received)
return {
pass,
message: () => pass
? `expected element not to be in the document`
: `expected element to be in the document`
}
}
})
// Helper functions
const fillForm = async (formData) => {
for (const [field, value] of Object.entries(formData)) {
const input = screen.getByLabelText(new RegExp(field, 'i'))
await userEvent.clear(input)
await userEvent.type(input, value)
}
}
// Performance testing with act
test('renders large list efficiently', () => {
const startTime = performance.now()
act(() => {
render(<LargeList items={Array(1000).fill({})} />)
})
const endTime = performance.now()
const renderTime = endTime - startTime
expect(renderTime).toBeLessThan(100) // Should render in under 100ms
})
// Memory leak testing
test('cleans up event listeners on unmount', () => {
const { unmount } = render(<ComponentWithListeners />)
// Check if event listeners are cleaned up
const addEventListenerSpy = jest.spyOn(document, 'addEventListener')
unmount()
expect(addEventListenerSpy).not.toHaveBeenCalled()
})
Integrate testing into your development workflow:
Pre-commit Hooks: Run tests before commits Pull Request Tests: Run full test suite on PRs Scheduled Tests: Run comprehensive tests periodically Coverage Reports: Monitor test coverage trends
// package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:ci": "jest --ci --coverage --watchAll=false"
}
}
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/**/*.stories.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
In this comprehensive lesson, you've mastered testing strategies for React applications. You now understand:
In the next lesson, you'll learn about performance optimization techniques, ensuring your tested applications run efficiently and provide excellent user experiences.