SPA Testing — Unit, Integration, and E2E Tests for Single-Page Applications
In this tutorial, you will learn about SPA Testing. We cover key concepts, practical examples, and best practices to help you master this topic.
SPA testing covers unit tests with Jest and Vitest, integration tests with React Testing Library, component tests with Storybook, end-to-end tests with Cypress or Playwright, and testing strategies for state management and async operations.
What You'll Learn
By the end of this tutorial, you will understand the testing pyramid for SPAs, how to write unit tests for utility functions and hooks, integration tests for components and pages, end-to-end tests for user flows, and strategies for testing async operations, state management, and API interactions.
Why It Matters
Without tests, every change risks breaking existing functionality. As SPAs grow, manual testing becomes impractical. A comprehensive test suite catches regressions early, documents expected behavior, and gives confidence to refactor. Well-tested SPAs ship faster with fewer bugs.
Real-World Use
An e-commerce SPA with 500+ components adopted testing with React Testing Library and Cypress. Before: 2-3 production bugs per week, deploy cycle of 2 weeks. After: 90 percent test coverage, 0 critical bugs in 3 months, and daily deployments with confidence.
SPA Testing Pyramid
┌──────────────────────────────────────────────────────────┐
│ Testing Pyramid │
├──────────────────────────────────────────────────────────┤
│ │
│ / \ │
│ / E2E \ │
│ / (5%) \ │
│ /──────────\ │
│ / Integration \ │
│ / (15%) \ │
│ /─────────────────\ │
│ / Unit Tests \ │
│ / (80%) \ │
│ /────────────────────────\ │
│ │
│ Unit: Test individual functions, hooks, utilities │
│ Integration: Test component interactions, API calls │
│ E2E: Test complete user flows in the browser │
└──────────────────────────────────────────────────────────┘
Think of testing like checking a car before driving. Unit tests check each part individually (does the brake light bulb work?). Integration tests check if parts work together (do the brakes stop the car?). E2E tests check the complete experience (can you drive the car from home to work without crashing?).
Unit Testing with Vitest
import { describe, it, expect, vi } from 'vitest';
// Pure utility function to test
function formatPrice(amount, currency = 'USD') {
if (typeof amount !== 'number' || isNaN(amount)) {
return '$0.00';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(amount);
}
describe('formatPrice', () => {
it('formats USD correctly', () => {
expect(formatPrice(10.99)).toBe('$10.99');
});
it('handles zero', () => {
expect(formatPrice(0)).toBe('$0.00');
});
it('handles large numbers with commas', () => {
expect(formatPrice(1234567.89)).toBe('$1,234,567.89');
});
it('handles invalid input gracefully', () => {
expect(formatPrice(NaN)).toBe('$0.00');
expect(formatPrice('abc')).toBe('$0.00');
});
it('supports different currencies', () => {
expect(formatPrice(10.99, 'EUR')).toBe('€10.99');
});
});
// Hook test
import { renderHook, act } from '@testing-library/react';
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(10));
act(() => result.current.increment());
expect(result.current.count).toBe(11);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
});
Integration Testing with React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
// Mock API server
const server = setupServer(
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.json([
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 19.99 }
]));
}),
rest.post('/api/cart', (req, res, ctx) => {
return res(ctx.json({ success: true }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Component test
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/products')
.then(r => r.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <div data-testid="loading">Loading...</div>;
return (
<ul>
{products.map(product => (
<li key={product.id}>
<span>{product.name}</span>
<span>${product.price}</span>
<button>Add to Cart</button>
</li>
))}
</ul>
);
}
describe('ProductList', () => {
it('shows loading state initially', () => {
render(<ProductList />);
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
it('displays products after loading', async () => {
render(<ProductList />);
await waitFor(() => {
expect(screen.getByText('Widget')).toBeInTheDocument();
});
expect(screen.getByText('$9.99')).toBeInTheDocument();
expect(screen.getByText('Gadget')).toBeInTheDocument();
});
it('handles API errors gracefully', async () => {
server.use(
rest.get('/api/products', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
End-to-End Testing with Playwright
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart Flow', () => {
test('user can add product to cart and checkout', async ({ page }) => {
// Navigate to home page
await page.goto('/');
await expect(page).toHaveTitle(/Shop/);
// Find and click a product
await page.click('text=Widget');
await expect(page.locator('h1')).toContainText('Widget');
// Add to cart
await page.click('button:has-text("Add to Cart")');
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
// View cart
await page.click('[data-testid="cart-icon"]');
await expect(page.locator('[data-testid="cart-item"]')).toContainText('Widget');
// Checkout
await page.click('button:has-text("Checkout")');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="address"]', '123 Main St');
await page.click('button:has-text("Place Order")');
// Verify order confirmation
await expect(page.locator('h1')).toContainText('Order Confirmed');
await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
});
test('shows error for empty cart checkout', async ({ page }) => {
await page.goto('/cart');
await page.click('button:has-text("Checkout")');
await expect(page.locator('[data-testid="error"]'))
.toContainText('Your cart is empty');
});
});
Common Mistakes
- Testing implementation details instead of behavior. Testing that a state variable equals a specific value instead of testing what the user sees. Always test from the user's perspective.
- Mocking too much. Mocking fetch and every dependency makes tests fast but meaningless. Test real integrations where possible, and only mock external services.
- Not testing error states. Only testing the happy path leaves error handling untested. Always test loading, empty, and error states.
- Flaky E2E tests from race conditions. Using hard-coded timeouts instead of waiting for elements. Use
waitFor,findBy, and other retry-based assertions. - Ignoring Accessibility in tests. Using test IDs instead of accessible queries. Prefer
getByRole,getByLabelText, andgetByTextovergetByTestId.
Practice Questions
- What is the testing pyramid and what percentage of tests should be at each level?
- Why should you test behavior instead of implementation details?
- How do you test async API calls in React components?
- What is the difference between unit, integration, and E2E tests?
- Why are accessible queries (getByRole) preferred over test IDs?
Challenge: Set up a complete testing infrastructure for an SPA: Vitest for unit tests with 80 percent coverage on utility functions, React Testing Library for integration tests on 3 key pages, Playwright for 5 critical user flows (login, search, add to cart, checkout, logout), and MSW for API mocking. Achieve 90 percent test coverage.
FAQ
Mini Project
Set up a complete test suite for a todo list SPA: unit tests for the todo filtering and sorting logic, integration tests for the todo form (add, edit, delete, toggle completion), E2E tests for the complete user flow, MSW mocking for API calls, and a CI pipeline that runs tests on every Pull Request.
What's Next
You understand SPA testing. Now explore SPA performance optimization to make your tested application fast and responsive.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro