Skip to content
Sameer Sabir
Best Practices

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.

Sameer SabirUpdated 12 min read
  • Testing
  • React
  • Jest
  • Testing Library
  • Playwright
  • E2E
  • Quality Assurance

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 userEvent instead of fireEvent (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.

A test suite nobody trusts is worse than no test suite. If your team is at that point, I take on code review and testing engagements alongside build work — tell me where things stand.

  1. Effective Code Review Practices for Frontend Engineering TeamsBest Practices · 12 min
  2. Should You Enable the React Compiler in Production?React · 10 min
  3. React Compiler vs useMemo and useCallback: What Actually ChangesReact · 11 min

Next step

Have a project that needs this kind of work?

I build React and Next.js products end to end, including the performance and AI work these posts cover. Tell me where yours is stuck.