Frontend Testing: Unit, Integration, and E2E

Frontend testing isn't about achieving 100% coverage—it's about building confidence that your application works while maintaining a test suite you can actually sustain. The testing pyramid provides a...

Key Insights

  • The testing pyramid prioritizes fast, cheap unit tests (70%) over slower integration (20%) and E2E tests (10%), balancing coverage with maintenance costs and execution speed.
  • Unit tests validate isolated logic and components, integration tests verify how pieces work together, and E2E tests confirm critical user journeys—each serves a distinct purpose that the others cannot replace.
  • Choose your testing level based on what you’re validating: business logic lives in unit tests, component interactions in integration tests, and complete workflows in E2E tests.

The Testing Pyramid for Frontend Applications

Frontend testing isn’t about achieving 100% coverage—it’s about building confidence that your application works while maintaining a test suite you can actually sustain. The testing pyramid provides a practical framework: write many fast unit tests, fewer integration tests, and only essential E2E tests.

Why this distribution? Unit tests run in milliseconds, require no browser, and pinpoint exact failures. Integration tests take seconds and catch interaction bugs. E2E tests take minutes, are brittle, and break when UI changes—but they’re the only way to verify complete user flows work in a real browser.

Here’s the distribution you should target:

// Conceptual breakdown (not actual code)
const testingPyramid = {
  unit: '70%',        // Fast, isolated, abundant
  integration: '20%', // Medium speed, component interactions
  e2e: '10%'          // Slow, expensive, critical paths only
};

The cost difference is real. A unit test suite with 500 tests runs in under 10 seconds. An E2E suite with 50 tests might take 20 minutes. This matters for developer experience and CI/CD pipeline costs.

Unit Testing: Testing Components in Isolation

Unit tests validate individual functions and components without external dependencies. They’re your first line of defense against regressions and should make up the bulk of your test suite.

Start with pure functions—they’re the easiest to test:

// utils/pricing.js
export function calculateDiscount(price, discountPercent) {
  if (price < 0 || discountPercent < 0 || discountPercent > 100) {
    throw new Error('Invalid input');
  }
  return price * (1 - discountPercent / 100);
}

// utils/pricing.test.js
import { calculateDiscount } from './pricing';

describe('calculateDiscount', () => {
  it('applies discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
  });

  it('throws on negative price', () => {
    expect(() => calculateDiscount(-10, 20)).toThrow('Invalid input');
  });

  it('throws on invalid discount percentage', () => {
    expect(() => calculateDiscount(100, 150)).toThrow('Invalid input');
  });
});

For React components, use React Testing Library to test behavior, not implementation:

// components/Counter.jsx
import { useState } from 'react';

export function Counter({ initialCount = 0 }) {
  const [count, setCount] = useState(initialCount);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

// components/Counter.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';

describe('Counter', () => {
  it('starts with initial count', () => {
    render(<Counter initialCount={5} />);
    expect(screen.getByText('Count: 5')).toBeInTheDocument();
  });

  it('increments count when button clicked', () => {
    render(<Counter />);
    const button = screen.getByText('Increment');
    
    fireEvent.click(button);
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
    
    fireEvent.click(button);
    expect(screen.getByText('Count: 2')).toBeInTheDocument();
  });

  it('resets count to zero', () => {
    render(<Counter initialCount={10} />);
    
    fireEvent.click(screen.getByText('Reset'));
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });
});

Mock external dependencies to keep tests isolated and fast:

// hooks/useUser.js
import { fetchUser } from '../api/users';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then(data => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  return { user, loading };
}

// hooks/useUser.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from './useUser';
import * as api from '../api/users';

jest.mock('../api/users');

describe('useUser', () => {
  it('fetches and returns user data', async () => {
    const mockUser = { id: 1, name: 'Alice' };
    api.fetchUser.mockResolvedValue(mockUser);

    const { result } = renderHook(() => useUser(1));

    expect(result.current.loading).toBe(true);
    
    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });
    
    expect(result.current.user).toEqual(mockUser);
    expect(api.fetchUser).toHaveBeenCalledWith(1);
  });
});

Integration Testing: Testing Component Interactions

Integration tests verify that multiple components work together correctly. They catch bugs that unit tests miss—like passing wrong props, context not propagating, or state updates not triggering re-renders.

Test forms with multiple components as an integrated unit:

// components/UserForm.jsx
import { useState } from 'react';
import { Input } from './Input';
import { Button } from './Button';

export function UserForm({ onSubmit }) {
  const [formData, setFormData] = useState({ name: '', email: '' });

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <Input
        label="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <Input
        label="Email"
        type="email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
      />
      <Button type="submit">Submit</Button>
    </form>
  );
}

// components/UserForm.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UserForm } from './UserForm';

describe('UserForm integration', () => {
  it('submits form with all input values', () => {
    const handleSubmit = jest.fn();
    render(<UserForm onSubmit={handleSubmit} />);

    fireEvent.change(screen.getByLabelText('Name'), {
      target: { value: 'John Doe' }
    });
    fireEvent.change(screen.getByLabelText('Email'), {
      target: { value: 'john@example.com' }
    });
    
    fireEvent.click(screen.getByText('Submit'));

    expect(handleSubmit).toHaveBeenCalledWith({
      name: 'John Doe',
      email: 'john@example.com'
    });
  });
});

Test state management integration using Mock Service Worker for realistic API mocking:

// setup/msw.js
import { setupServer } from 'msw/node';
import { rest } from 'msw';

export const server = setupServer(
  rest.get('/api/products', (req, res, ctx) => {
    return res(ctx.json([
      { id: 1, name: 'Product 1', price: 29.99 },
      { id: 2, name: 'Product 2', price: 49.99 }
    ]));
  })
);

// components/ProductList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { ProductList } from './ProductList';
import { server } from '../setup/msw';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('ProductList with API integration', () => {
  it('fetches and displays products', async () => {
    render(<ProductList />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Product 1')).toBeInTheDocument();
      expect(screen.getByText('Product 2')).toBeInTheDocument();
    });
  });
});

End-to-End Testing: Testing Complete User Flows

E2E tests run in real browsers and validate entire user journeys. They’re expensive to maintain but irreplaceable for critical paths like authentication, checkout, or onboarding.

Use Cypress for developer-friendly E2E testing:

// cypress/e2e/registration.cy.js
describe('User Registration Flow', () => {
  it('allows new user to register and login', () => {
    cy.visit('/register');

    cy.get('input[name="email"]').type('newuser@example.com');
    cy.get('input[name="password"]').type('SecurePass123!');
    cy.get('input[name="confirmPassword"]').type('SecurePass123!');
    
    cy.get('button[type="submit"]').click();

    // Should redirect to dashboard after registration
    cy.url().should('include', '/dashboard');
    cy.contains('Welcome, newuser@example.com');
  });

  it('shows validation errors for invalid input', () => {
    cy.visit('/register');

    cy.get('input[name="email"]').type('invalid-email');
    cy.get('button[type="submit"]').click();

    cy.contains('Please enter a valid email address');
  });
});

Or use Playwright for better performance and multi-browser testing:

// tests/checkout.spec.js
import { test, expect } from '@playwright/test';

test('complete checkout process', async ({ page }) => {
  await page.goto('/products');

  // Add item to cart
  await page.click('text=Add to Cart');
  await page.click('text=View Cart');

  // Verify cart contents
  await expect(page.locator('.cart-item')).toHaveCount(1);

  // Proceed to checkout
  await page.click('text=Checkout');
  
  // Fill shipping information
  await page.fill('input[name="address"]', '123 Main St');
  await page.fill('input[name="city"]', 'San Francisco');
  await page.fill('input[name="zipCode"]', '94102');

  // Submit order
  await page.click('button:has-text("Place Order")');

  // Verify confirmation
  await expect(page.locator('text=Order Confirmed')).toBeVisible();
  await expect(page.locator('.order-number')).toContainText('#');
});

Choosing the Right Test Type & Best Practices

Use this decision framework:

Unit tests for: Business logic, utility functions, individual component behavior, state transformations, input validation.

Integration tests for: Form submissions, component composition, state management flows, API interactions, context providers.

E2E tests for: Authentication flows, payment processing, multi-step wizards, critical revenue paths, cross-page navigation.

Here’s the same feature tested at all three levels:

// UNIT: Test discount calculation logic
test('calculateTotal applies discount', () => {
  expect(calculateTotal(100, 0.2)).toBe(80);
});

// INTEGRATION: Test cart component with discount
test('Cart displays discounted total', () => {
  render(<Cart items={items} discount={0.2} />);
  expect(screen.getByText('Total: $80.00')).toBeInTheDocument();
});

// E2E: Test complete checkout with discount code
test('user applies discount code at checkout', async ({ page }) => {
  await page.goto('/cart');
  await page.fill('input[name="discountCode"]', 'SAVE20');
  await page.click('text=Apply');
  await expect(page.locator('.total')).toHaveText('$80.00');
});

Configure CI/CD to run tests efficiently:

# .github/workflows/test.yml
name: Test Suite
on: [push, pull_request]

jobs:
  unit-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run test:unit
      - run: npm run test:integration

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npm run build
      - run: npx playwright install --with-deps
      - run: npm run test:e2e

Tools & Setup Recommendations

Here’s a production-ready testing setup:

{
  "devDependencies": {
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.1.0",
    "@testing-library/user-event": "^14.5.0",
    "jest": "^29.7.0",
    "jest-environment-jsdom": "^29.7.0",
    "msw": "^2.0.0",
    "cypress": "^13.6.0",
    "@playwright/test": "^1.40.0"
  },
  "scripts": {
    "test:unit": "jest --testPathPattern=\\.test\\.(js|jsx)$",
    "test:integration": "jest --testPathPattern=\\.integration\\.test\\.(js|jsx)$",
    "test:e2e:cypress": "cypress run",
    "test:e2e:playwright": "playwright test",
    "test": "npm run test:unit && npm run test:integration"
  }
}

Start simple: get unit tests working first, add integration tests for complex interactions, and only write E2E tests for features that directly impact revenue or user trust. Your test suite should give you confidence to ship, not become a maintenance burden that slows you down.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.