Testing Strategies

Unit, integration, and end-to-end testing for React applications with practical examples.

Intermediate · 18 min read

The Testing Pyramid

The testing pyramid guides how many tests to write at each level. Many fast unit tests at the base, some integration tests in the middle, and few slow E2E tests at the top. This balances confidence with feedback speed.

Testing Pyramid: Unit (base) → Integration (middle) → E2E (top)

Unit Tests

Unit tests verify individual functions, hooks, or components in isolation. They are fast, deterministic, and cheap to run.

import { describe, it, expect } from 'vitest';
import { formatCurrency, clamp } from './utils';

describe('formatCurrency', () => {
  it('formats USD correctly', () => {
    expect(formatCurrency(1234.5, 'USD')).toBe('$1,234.50');
  });

  it('handles zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00');
  });

  it('handles negative amounts', () => {
    expect(formatCurrency(-42.1, 'USD')).toBe('-$42.10');
  });
});

describe('clamp', () => {
  it('clamps below minimum', () => {
    expect(clamp(-5, 0, 100)).toBe(0);
  });

  it('clamps above maximum', () => {
    expect(clamp(150, 0, 100)).toBe(100);
  });

  it('returns value when in range', () => {
    expect(clamp(50, 0, 100)).toBe(50);
  });
});

Integration Tests: React Testing Library

Integration tests verify that multiple units work together. React Testing Library encourages testing from the user's perspective — interacting with rendered output rather than internal implementation.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  it('submits with valid credentials', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    const user = userEvent.setup();

    await user.type(screen.getByLabelText(/email/i), 'user@example.com');
    await user.type(screen.getByLabelText(/password/i), 'securePass123');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: 'user@example.com',
        password: 'securePass123',
      });
    });
  });

  it('shows validation errors for empty fields', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    const user = userEvent.setup();
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
    expect(screen.getByText(/password is required/i)).toBeInTheDocument();
  });

  it('disables submit button while loading', async () => {
    render(<LoginForm onSubmit={() => new Promise(() => {})} />);

    const user = userEvent.setup();
    await user.type(screen.getByLabelText(/email/i), 'a@b.com');
    await user.type(screen.getByLabelText(/password/i), 'pass');
    await user.click(screen.getByRole('button', { name: /sign in/i }));

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
  });
});

What to Test vs What Not to Test

Test These Skip These
User interactions (click, type, submit) Implementation details (internal state values)
Conditional rendering (loading, error, empty states) CSS styles and visual appearance (use visual tests)
Form validation and error messages Third-party library internals
Business logic functions Static content that rarely changes
Accessibility (roles, labels, focus management) Framework behavior (React itself)
Edge cases and boundary values Snapshot tests of large component trees

E2E Tests with Playwright

import { test, expect } from '@playwright/test';

test.describe('Checkout Flow', () => {
  test('completes purchase successfully', async ({ page }) => {
    await page.goto('/products');

    // Add item to cart
    await page.getByRole('button', { name: 'Add to Cart' }).first().click();
    await expect(page.getByTestId('cart-count')).toHaveText('1');

    // Go to checkout
    await page.getByRole('link', { name: 'Cart' }).click();
    await page.getByRole('button', { name: 'Checkout' }).click();

    // Fill shipping form
    await page.getByLabel('Full Name').fill('Jane Doe');
    await page.getByLabel('Address').fill('123 Main St');
    await page.getByLabel('City').fill('Springfield');

    // Submit order
    await page.getByRole('button', { name: 'Place Order' }).click();

    // Verify confirmation
    await expect(page.getByText('Order Confirmed')).toBeVisible();
    await expect(page.getByTestId('order-number')).toBeVisible();
  });
});

Testing Tools Comparison

Tool Type Speed Best For
Vitest Unit / Integration Very fast Vite projects, modern setup
Jest Unit / Integration Fast Established projects, broad ecosystem
React Testing Library Integration Fast User-centric component testing
Playwright E2E Slow Cross-browser, full flow testing
Cypress E2E Slow Developer-friendly E2E, component tests
MSW (Mock Service Worker) API Mocking Fast Intercepting network requests in tests

TIP: Follow the AAA pattern: Arrange (set up test data and render), Act (simulate user actions), Assert (verify expected outcomes). This makes tests readable and consistent.

NOTE: Run unit and integration tests on every commit (CI). Run E2E tests on pull requests or nightly builds. This keeps the feedback loop fast while still catching regressions.


Part of the Frontend Patterns series on Tekivex. Browse all tutorials or explore our open-source products.