Testing Strategies for React Applications: Unit, Integration, and E2E Best Practices
Master testing React apps with Jest, React Testing Library, Vitest, and Playwright. Complete guide to unit, integration, and E2E testing strategies.
Testing Strategies for React Applications: Unit, Integration, and E2E Best Practices
Writing tests is the single most underrated skill in frontend development. Paradoxically, teams that invest heavily in testing move faster, not slower. Bug fixes take less time, refactoring becomes confident, and regressions are caught before production.
I've worked with codebases that had near-zero test coverage and ones with 95%+ coverage. The difference in velocity, confidence, and developer happiness is staggering. The 95% coverage team ships with 10x fewer bugs and can refactor without fear.
The challenge isn't writing good tests—it's understanding what to test and how to structure tests so they're maintainable and valuable.
The Testing Pyramid: Modern Approach
The traditional testing pyramid (many unit tests, fewer integration tests, few E2E tests) is outdated. The modern approach flips it based on ROI:
E2E Tests (5-10%)
High ROI per test
✓ Catch real user flows
✓ Prevent production issues
Integration Tests (20-30%)
Good ROI for critical paths
✓ Test component interactions
✓ Validate data flow
Unit Tests (60-70%)
Quick feedback, cheap to write
✓ Test pure functions
✓ Edge case coverage
Modern ratio: 60% unit, 30% integration, 10% E2E.
Unit Testing: Speed and Coverage
Unit tests are fast (milliseconds) and cheap (easy to write). Use them for:
- Pure functions (utils, helpers)
- Business logic
- Complex algorithms
- Edge cases
// lib/calculations.ts
export function calculateDiscount(price: number, discountPercent: number): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new Error('Discount must be between 0 and 100');
}
return price * (1 - discountPercent / 100);
}
export function formatPrice(price: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price);
}
// lib/__tests__/calculations.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDiscount, formatPrice } from '../calculations';
describe('calculateDiscount', () => {
it('should calculate correct discount', () => {
expect(calculateDiscount(100, 10)).toBe(90);
expect(calculateDiscount(100, 50)).toBe(50);
});
it('should handle edge cases', () => {
expect(calculateDiscount(100, 0)).toBe(100);
expect(calculateDiscount(100, 100)).toBe(0);
});
it('should throw on invalid discount', () => {
expect(() => calculateDiscount(100, -10)).toThrow();
expect(() => calculateDiscount(100, 150)).toThrow();
});
});
describe('formatPrice', () => {
it('should format price as currency', () => {
expect(formatPrice(99.99)).toBe('$99.99');
expect(formatPrice(1000)).toBe('$1,000.00');
});
it('should handle zero and negatives', () => {
expect(formatPrice(0)).toBe('$0.00');
expect(formatPrice(-50)).toBe('-$50.00');
});
});
Key point: Test the contract, not the implementation. If you change how the function works internally but the output is same, tests should still pass.
Integration Testing: Testing Component Interactions
Integration tests verify that components work together correctly. Use React Testing Library's guiding principle: test behavior, not implementation.
// components/__tests__/ProductCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ProductCard } from '../ProductCard';
const mockProduct = {
id: '1',
name: 'Laptop',
price: 999.99,
inStock: true,
};
describe('ProductCard', () => {
it('should display product information', () => {
render(<ProductCard product={mockProduct} />);
expect(screen.getByText('Laptop')).toBeInTheDocument();
expect(screen.getByText('$999.99')).toBeInTheDocument();
});
it('should handle add to cart action', async () => {
const onAddToCart = vi.fn();
render(
<ProductCard
product={mockProduct}
onAddToCart={onAddToCart}
/>
);
const addButton = screen.getByRole('button', { name: /add to cart/i });
await userEvent.click(addButton);
expect(onAddToCart).toHaveBeenCalledWith(mockProduct.id);
});
it('should show out of stock state', () => {
render(
<ProductCard
product={{ ...mockProduct, inStock: false }}
/>
);
expect(screen.getByText(/out of stock/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add to cart/i }))
.toBeDisabled();
});
it('should update quantity', async () => {
render(<ProductCard product={mockProduct} />);
const increaseButton = screen.getByRole('button', { name: /increase/i });
await userEvent.click(increaseButton);
await userEvent.click(increaseButton);
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
});
});
Best practices:
- Query by role, label, or text — in that order
- Avoid testing implementation details (component state)
- Use
userEventinstead offireEvent(more realistic) - Test user workflows, not code paths
Testing Async Behavior and API Calls
// hooks/__tests__/useProducts.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useProducts } from '../useProducts';
vi.mock('@/lib/api', () => ({
fetchProducts: vi.fn(),
}));
import { fetchProducts } from '@/lib/api';
describe('useProducts', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch products on mount', async () => {
const mockProducts = [
{ id: '1', name: 'Product 1' },
{ id: '2', name: 'Product 2' },
];
vi.mocked(fetchProducts).mockResolvedValueOnce(mockProducts);
const { result } = renderHook(() => useProducts());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.products).toEqual(mockProducts);
});
it('should handle fetch error', async () => {
const error = new Error('API Error');
vi.mocked(fetchProducts).mockRejectedValueOnce(error);
const { result } = renderHook(() => useProducts());
await waitFor(() => {
expect(result.current.error).toBe(error);
});
expect(result.current.products).toEqual([]);
});
});
E2E Testing: Real User Flows
E2E tests run against a real application and simulate actual user behavior. They're slower but catch integration issues unit tests miss.
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Navigate to app
await page.goto('http://localhost:3000');
});
test('should complete purchase successfully', async ({ page }) => {
// Browse products
await page.click('text=Browse Products');
await page.waitForURL('**/products');
// Add product to cart
const firstProduct = page.locator('[data-testid="product-card"]').first();
await firstProduct.hover();
await firstProduct.click('[data-testid="add-to-cart"]');
// Verify cart notification
await expect(page.locator('text=Added to cart')).toBeVisible();
// Go to checkout
await page.click('[data-testid="cart-icon"]');
await page.click('text=Proceed to Checkout');
await page.waitForURL('**/checkout');
// Fill billing info
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="cardNumber"]', '4111111111111111');
await page.fill('[name="expiry"]', '12/25');
await page.fill('[name="cvc"]', '123');
// Submit order
await page.click('button:has-text("Complete Purchase")');
// Verify success
await page.waitForURL('**/order-confirmation/**');
await expect(page.locator('text=Order Confirmed')).toBeVisible();
// Check email was sent
const orderNumber = await page.locator('[data-testid="order-number"]').textContent();
expect(orderNumber).toMatch(/^ORD-/);
});
test('should handle payment failure gracefully', async ({ page }) => {
await page.click('text=Browse Products');
const product = page.locator('[data-testid="product-card"]').first();
await product.click('[data-testid="add-to-cart"]');
await page.click('[data-testid="cart-icon"]');
await page.click('text=Proceed to Checkout');
// Use declined card
await page.fill('[name="cardNumber"]', '4000000000000002');
await page.fill('[name="expiry"]', '12/25');
await page.fill('[name="cvc"]', '123');
await page.click('button:has-text("Complete Purchase")');
// Verify error message
await expect(
page.locator('text=Card was declined')
).toBeVisible();
// Should still be on checkout page
await expect(page).toHaveURL('**/checkout');
});
});
Run E2E tests:
npx playwright test
npx playwright test --debug # Interactive mode
npx playwright test --headed # See browser
Test Organization Best Practices
src/
├── components/
│ ├── Button.tsx
│ └── __tests__/
│ └── Button.test.tsx
├── hooks/
│ ├── useAuth.ts
│ └── __tests__/
│ └── useAuth.test.ts
├── lib/
│ ├── utils.ts
│ └── __tests__/
│ └── utils.test.ts
└── e2e/
├── auth.spec.ts
├── checkout.spec.ts
└── user-profile.spec.ts
Key organization principles:
- Keep tests near implementation
- One test file per component/hook
- E2E tests in separate folder
- Use descriptive test names
Common Testing Anti-Patterns
❌ Testing Implementation Details
// BAD: Testing internal state
it('should set isLoading to false', () => {
const { result } = renderHook(() => useData());
expect(result.current.isLoading).toBe(false);
});
// GOOD: Testing observable behavior
it('should display data when loaded', async () => {
render(<DataComponent />);
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});
❌ Over-Mocking
// BAD: Mocking everything makes tests brittle
vi.mock('@/components/Button', () => ({
Button: () => <div>Mocked Button</div>
}));
// GOOD: Only mock external dependencies
vi.mock('@/lib/api');
vi.mock('@/services/analytics');
❌ Testing Too Much in One Test
// BAD: Multiple assertions on different features
it('should work', () => {
render(<Form />);
expect(screen.getByText('Form Title')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button'));
expect(mockFn).toHaveBeenCalled();
expect(screen.getByText('Success')).toBeInTheDocument();
});
// GOOD: One concept per test
it('should display form title', () => {
render(<Form />);
expect(screen.getByText('Form Title')).toBeInTheDocument();
});
it('should submit form on button click', async () => {
render(<Form />);
await userEvent.click(screen.getByRole('button'));
expect(mockFn).toHaveBeenCalled();
});
Test Coverage Targets
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'istanbul',
reporter: ['text', 'json', 'html'],
lines: 70,
functions: 70,
branches: 65,
statements: 70
}
}
});
Target coverage by type:
- Utilities/Helpers: 90-100%
- Components: 80-90%
- Hooks: 80-90%
- Pages: 60-70%
Conclusion
Testing isn't a burden—it's an accelerant. Teams with solid test coverage ship faster, with fewer bugs, and with more confidence. Start with integration tests (highest ROI), add unit tests for complex logic, then E2E tests for critical user flows.
The investment pays off immediately: less time debugging, more time shipping.