Skip to content

SPA Testing — Unit, Integration, and E2E Tests for Single-Page Applications

DodaTech Updated 2026-06-28 6 min read

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

  1. 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.
  2. Mocking too much. Mocking fetch and every dependency makes tests fast but meaningless. Test real integrations where possible, and only mock external services.
  3. Not testing error states. Only testing the happy path leaves error handling untested. Always test loading, empty, and error states.
  4. Flaky E2E tests from race conditions. Using hard-coded timeouts instead of waiting for elements. Use waitFor, findBy, and other retry-based assertions.
  5. Ignoring Accessibility in tests. Using test IDs instead of accessible queries. Prefer getByRole, getByLabelText, and getByText over getByTestId.

Practice Questions

  1. What is the testing pyramid and what percentage of tests should be at each level?
  2. Why should you test behavior instead of implementation details?
  3. How do you test async API calls in React components?
  4. What is the difference between unit, integration, and E2E tests?
  5. 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

Should I use Jest or Vitest for SPA testing?

Vitest is faster and natively supports ESM and TypeScript. It is the recommended choice for Vite-based SPAs. Jest is still widely used but slower for large projects.

How do I test components that use third-party libraries?

Mock the third-party library at the module level using vi.mock. Only mock what you need — let the component render naturally as much as possible.

What is the difference between getBy, findBy, and queryBy?

getBy throws if the element is not found. findBy returns a promise that waits for the element to appear. queryBy returns null if not found (for asserting absence).

How do I test WebSocket connections in SPAs?

Mock the WebSocket class using vi.stubGlobal. Create a fake WebSocket that emits events based on test scenarios. Test normal messages, reconnection, and connection errors.

Should I test CSS styles?

No. Test behavior and content, not visual styles. Use visual regression tools like Percy or Chromatic for visual testing if needed.

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