#Tech#Web Development#Programming#Testing#React

Testing React Apps: Complete Guide

A comprehensive guide to testing React applications, covering unit, integration, and E2E testing with best practices.

Testing React Apps: The Complete Guide

Testing is essential for building reliable React applications. It catches bugs early, prevents regressions, and gives confidence when refactoring code. But testing React applications effectively requires understanding different testing strategies and tools.

This comprehensive guide covers everything you need to know about testing React applications, from basic concepts to advanced patterns.

Types of Testing

Testing Pyramid

           E2E Tests
          (Few, Slow)
              /\
             /  \
            /      \
          /          \
        /              \
      /                  \
    Integration Tests      (Many, Fast)
    \                  /
      \              /
        \          /
          \      /
            \  /
           Unit Tests
         (Most, Fastest)

Unit Tests: Test individual components/functions in isolation Integration Tests: Test multiple components working together E2E Tests: Test complete user flows through the application

When to Use Each

Test TypeCoverageSpeedUse Case
UnitHighFastComponent logic, pure functions
IntegrationMediumMediumComponent composition, hooks
E2ELowSlowCritical user flows

Setting Up Testing Environment

Jest + React Testing Library

# Install dependencies
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/main.js',
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
}
// jest.setup.js
import '@testing-library/jest-dom'
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

Vitest + React Testing Library

# Install Vitest
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @vitest/coverage-v8
// vitest.config.js
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
      ],
    },
  },
})
// src/test/setup.ts
import { expect, afterEach } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'

afterEach(() => {
  cleanup()
})

Unit Testing

Testing Components

// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'

describe('Button', () => {
  it('renders correctly', () => {
    render(<Button>Click me</Button>)

    expect(screen.getByRole('button')).toHaveTextContent('Click me')
  })

  it('applies variant styles', () => {
    const { container } = render(<Button variant="primary">Primary</Button>)

    expect(container.firstChild).toHaveClass('btn-primary')
  })

  it('handles click events', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)

    const button = screen.getByRole('button')
    fireEvent.click(button)

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>)

    const button = screen.getByRole('button')
    expect(button).toBeDisabled()
  })
})

Testing Hooks

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())

    expect(result.current.count).toBe(0)
  })

  it('increments count', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('decrements count', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.decrement()
    })

    expect(result.current.count).toBe(-1)
  })

  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.reset()
    })

    expect(result.current.count).toBe(0)
  })
})

Testing Custom Hooks with Dependencies

// useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { useFetch } from './useFetch'

// Mock fetch
global.fetch = jest.fn() as jest.Mock

describe('useFetch', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('fetches data on mount', async () => {
    const mockData = { name: 'Test User' }
    ;(global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    })

    const { result } = renderHook(() => useFetch('/api/users/1'))

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData)
    })

    expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
  })

  it('handles loading state', () => {
    ;(global.fetch as jest.Mock).mockImplementation(() => {
      return new Promise(() => {}) // Never resolves
    })

    const { result } = renderHook(() => useFetch('/api/users/1'))

    expect(result.current.loading).toBe(true)
  })

  it('handles errors', async () => {
    ;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'))

    const { result } = renderHook(() => useFetch('/api/users/1'))

    await waitFor(() => {
      expect(result.current.error).toBeInstanceOf(Error)
    })
  })
})

Integration Testing

Testing Component Composition

// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Mock API responses
jest.mock('../api/users', () => ({
  getUser: jest.fn(),
}))

const { getUser } = require('../api/users')

describe('UserProfile', () => {
  it('displays user data', async () => {
    const mockUser = {
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    }

    ;(getUser as jest.Mock).mockResolvedValueOnce(mockUser)

    const queryClient = new QueryClient()

    render(
      <QueryClientProvider client={queryClient}>
        <UserProfile userId="1" />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText(mockUser.name)).toBeInTheDocument()
      expect(screen.getByText(mockUser.email)).toBeInTheDocument()
    })
  })

  it('shows loading state', () => {
    ;(getUser as jest.Mock).mockImplementation(() => {
      return new Promise(() => {}) // Never resolves
    })

    const queryClient = new QueryClient()

    render(
      <QueryClientProvider client={queryClient}>
        <UserProfile userId="1" />
      </QueryClientProvider>
    )

    expect(screen.getByRole('progressbar')).toBeInTheDocument()
  })

  it('shows error state', async () => {
    ;(getUser as jest.Mock).mockRejectedValueOnce(new Error('User not found'))

    const queryClient = new QueryClient()

    render(
      <QueryClientProvider client={queryClient}>
        <UserProfile userId="1" />
      </QueryClientProvider>
    )

    await waitFor(() => {
      expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
    })
  })
})

Testing with Context

// ThemeProvider.test.tsx
import { render, screen } from '@testing-library/react'
import { ThemeProvider, useTheme } from './ThemeProvider'

describe('ThemeProvider', () => {
  it('provides theme context', () => {
    function TestComponent() {
      const { theme } = useTheme()
      return <div data-testid="theme">{theme}</div>
    }

    render(
      <ThemeProvider theme="dark">
        <TestComponent />
      </ThemeProvider>
    )

    expect(screen.getByTestId('theme')).toHaveTextContent('dark')
  })

  it('updates theme when changed', () => {
    function TestComponent() {
      const { theme, setTheme } = useTheme()
      return (
        <div>
          <button onClick={() => setTheme('light')}>Change to light</button>
          <div data-testid="theme">{theme}</div>
        </div>
      )
    }

    render(
      <ThemeProvider theme="dark">
        <TestComponent />
      </ThemeProvider>
    )

    fireEvent.click(screen.getByRole('button'))

    expect(screen.getByTestId('theme')).toHaveTextContent('light')
  })
})

End-to-End Testing

Cypress Setup

# Install Cypress
npm install --save-dev cypress @cypress/react

npx cypress open
// cypress.config.js
import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      on('task', {
        async log(message) {
          console.log(message)
        },
      })
    },
    specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
    supportFile: false,
  },
})

Cypress E2E Tests

// cypress/e2e/user-flows.cy.ts
describe('User Flows', () => {
  beforeEach(() => {
    cy.visit('/')
  })

  it('should complete registration flow', () => {
    // Navigate to registration
    cy.get('[data-testid="nav-register"]').click()

    // Fill form
    cy.get('[data-testid="input-name"]').type('John Doe')
    cy.get('[data-testid="input-email"]').type('john@example.com')
    cy.get('[data-testid="input-password"]').type('password123')

    // Submit
    cy.get('[data-testid="btn-submit"]').click()

    // Verify success
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="welcome-message"]').should('contain', 'Welcome, John Doe')
  })

  it('should complete login flow', () => {
    // Navigate to login
    cy.get('[data-testid="nav-login"]').click()

    // Fill credentials
    cy.get('[data-testid="input-email"]').type('john@example.com')
    cy.get('[data-testid="input-password"]').type('password123')

    // Submit
    cy.get('[data-testid="btn-login"]').click()

    // Verify login
    cy.url().should('include', '/dashboard')
    cy.get('[data-testid="user-avatar"]').should('be.visible')
  })

  it('should complete purchase flow', () => {
    // Login
    cy.login('john@example.com', 'password123')

    // Navigate to products
    cy.get('[data-testid="nav-products"]').click()

    // Add product to cart
    cy.get('[data-testid="product-1"]').within(() => {
      cy.get('[data-testid="btn-add-to-cart"]').click()
    })

    // Navigate to cart
    cy.get('[data-testid="nav-cart"]').click()

    // Verify product in cart
    cy.get('[data-testid="cart-item-1"]').should('be.visible')

    // Checkout
    cy.get('[data-testid="btn-checkout"]').click()

    // Fill payment details
    cy.get('[data-testid="input-card-number"]').type('4242424242424242')
    cy.get('[data-testid="input-card-expiry"]').type('12/25')
    cy.get('[data-testid="input-card-cvc"]').type('123')

    // Submit payment
    cy.get('[data-testid="btn-pay"]').click()

    // Verify success
    cy.url().should('include', '/order-confirmation')
    cy.get('[data-testid="order-success"]').should('contain', 'Order placed successfully')
  })
})

Custom Cypress Commands

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      login(email: string, password: string): void
    }
  }
}

Cypress.Commands.add('login', (email, password) => {
  cy.session(
    [email, password],
    () => {
      cy.visit('/login')
      cy.get('[data-testid="input-email"]').type(email)
      cy.get('[data-testid="input-password"]').type(password)
      cy.get('[data-testid="btn-login"]').click()
      cy.url().should('include', '/dashboard')
    }
  )
})

Playwright Setup

# Install Playwright
npm install --save-dev @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
})

Playwright E2E Tests

// e2e/user-flows.spec.ts
import { test, expect } from '@playwright/test'

test.describe('User Flows', () => {
  test('should complete registration flow', async ({ page }) => {
    await page.goto('/')

    // Navigate to registration
    await page.click('[data-testid="nav-register"]')

    // Fill form
    await page.fill('[data-testid="input-name"]', 'John Doe')
    await page.fill('[data-testid="input-email"]', 'john@example.com')
    await page.fill('[data-testid="input-password"]', 'password123')

    // Submit
    await page.click('[data-testid="btn-submit"]')

    // Verify success
    await expect(page).toHaveURL(/\/dashboard/)
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome, John Doe')
  })

  test('should complete login flow', async ({ page }) => {
    await page.goto('/login')

    // Fill credentials
    await page.fill('[data-testid="input-email"]', 'john@example.com')
    await page.fill('[data-testid="input-password"]', 'password123')

    // Submit
    await page.click('[data-testid="btn-login"]')

    // Verify login
    await expect(page).toHaveURL(/\/dashboard/)
    await expect(page.locator('[data-testid="user-avatar"]')).toBeVisible()
  })
})

Testing Hooks

Testing useEffect

// useDataFetcher.test.ts
import { renderHook, act, waitFor } from '@testing-library/react'
import { useDataFetcher } from './useDataFetcher'

describe('useDataFetcher', () => {
  it('fetches data on mount', async () => {
    const mockData = { id: 1, name: 'Test' }
    jest.spyOn(global, 'fetch').mockResolvedValueOnce({
      ok: true,
      json: async () => mockData,
    })

    const { result } = renderHook(() => useDataFetcher('/api/data/1'))

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData)
    })

    expect(global.fetch).toHaveBeenCalledWith('/api/data/1')
  })

  it('re-fetches data when URL changes', async () => {
    const mockData = { id: 2, name: 'Test 2' }
    jest.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => mockData,
    })

    const { result, rerender } = renderHook(
      ({ url }) => useDataFetcher(url),
      { initialProps: { url: '/api/data/1' } }
    )

    // Change URL
    act(() => {
      rerender({ url: '/api/data/2' })
    })

    await waitFor(() => {
      expect(result.current.data).toEqual(mockData)
    })

    expect(global.fetch).toHaveBeenLastCalledWith('/api/data/2')
  })

  it('cleans up on unmount', async () => {
    jest.spyOn(global, 'fetch').mockResolvedValue({
      ok: true,
      json: async () => ({ id: 1, name: 'Test' }),
    })

    const { result, unmount } = renderHook(() => useDataFetcher('/api/data/1'))

    await waitFor(() => {
      expect(result.current.data).toBeDefined()
    })

    unmount()

    // Verify cleanup
    expect(global.fetch).toHaveBeenCalledTimes(1)
  })
})

Testing Custom Hooks

// useForm.test.ts
import { renderHook, act } from '@testing-library/react'
import { useForm } from './useForm'

describe('useForm', () => {
  it('initializes with initial values', () => {
    const { result } = renderHook(() =>
      useForm({ name: '', email: '' })
    )

    expect(result.current.values).toEqual({ name: '', email: '' })
  })

  it('updates values on change', () => {
    const { result } = renderHook(() =>
      useForm({ name: '', email: '' })
    )

    act(() => {
      result.current.handleChange('name', 'John Doe')
    })

    expect(result.current.values.name).toBe('John Doe')
  })

  it('validates on submit', () => {
    const validationSchema = {
      name: (value: string) => (value.length >= 3 ? '' : 'Name must be at least 3 characters'),
      email: (value: string) => (/^\S+@\S+\.\S+$/.test(value) ? '' : 'Invalid email'),
    }

    const { result } = renderHook(() =>
      useForm({ name: '', email: '' }, validationSchema)
    )

    let errors: Record<string, string> = {}
    const handleError = (name: string, error: string) => {
      errors[name] = error
    }

    act(() => {
      result.current.handleSubmit(handleError)
    })

    expect(Object.keys(errors)).toHaveLength(2)
    expect(errors.name).toBe('Name must be at least 3 characters')
    expect(errors.email).toBe('Invalid email')
  })
})

Mocking and Stubbing

Mocking API Calls

// api/users.ts
export async function getUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) {
    throw new Error('Failed to fetch user')
  }
  return response.json()
}
// api/users.test.ts
import { getUser } from './users'

// Mock fetch globally
global.fetch = jest.fn() as jest.Mock

describe('getUser', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('fetches user data', async () => {
    const mockUser = {
      id: '1',
      name: 'John Doe',
      email: 'john@example.com',
    }

    ;(global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    })

    const user = await getUser('1')

    expect(user).toEqual(mockUser)
    expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
  })

  it('throws error on failed request', async () => {
    ;(global.fetch as jest.Mock).mockResolvedValueOnce({
      ok: false,
      status: 404,
    })

    await expect(getUser('999')).rejects.toThrow('Failed to fetch user')
  })
})

Mocking React Context

// AuthContext.test.tsx
import { render, screen } from '@testing-library/react'
import { AuthProvider, useAuth } from './AuthContext'

// Create test wrapper
function createMockAuthContext() {
  const mockContext = {
    user: null,
    login: jest.fn(),
    logout: jest.fn(),
  }

  return {
    Provider: ({ children }) => (
      <AuthProvider value={mockContext}>
        {children}
      </AuthProvider>
    ),
    ...mockContext,
  }
}

describe('AuthContext', () => {
  it('provides default context', () => {
    function TestComponent() {
      const { user } = useAuth()
      return <div data-testid="user">{user ? user.name : 'Not logged in'}</div>
    }

    const { Provider } = createMockAuthContext()

    render(
      <Provider>
        <TestComponent />
      </Provider>
    )

    expect(screen.getByTestId('user')).toHaveTextContent('Not logged in')
  })

  it('calls login function', () => {
    function TestComponent() {
      const { login } = useAuth()
      return <button onClick={() => login('john@example.com', 'password')}>Login</button>
    }

    const { Provider, login } = createMockAuthContext()

    render(
      <Provider>
        <TestComponent />
      </Provider>
    )

    fireEvent.click(screen.getByRole('button'))

    expect(login).toHaveBeenCalledWith('john@example.com', 'password')
  })
})

Visual Regression Testing

Percy Setup

# Install Percy
npm install --save-dev @percy/cli @percy/playwright
// e2e/percy.spec.ts
import { test, expect } from '@playwright/test'
import { takeScreenshot } from '@percy/playwright'

test('homepage visual regression', async ({ page }) => {
  await page.goto('/')

  // Take screenshot for Percy
  await takeScreenshot(page, 'Homepage')

  // Verify element exists
  await expect(page.locator('[data-testid="hero-section"]')).toBeVisible()
})

test('user profile visual regression', async ({ page }) => {
  await page.goto('/profile/john-doe')

  // Take screenshot for Percy
  await takeScreenshot(page, 'User Profile')

  // Verify profile elements
  await expect(page.locator('[data-testid="profile-name"]')).toHaveText('John Doe')
  await expect(page.locator('[data-testid="profile-email"]')).toHaveText('john@example.com')
})

Best Practices

1. Test User Behavior, Not Implementation

// ❌ Bad: Testing implementation details
describe('Button', () => {
  it('has onClick prop', () => {
    render(<Button>Click me</Button>)

    // Testing implementation detail
    expect(document.querySelector('.button')).toHaveProperty('onclick')
  })
})

// ✅ Good: Testing user behavior
describe('Button', () => {
  it('triggers onClick when clicked', () => {
    const handleClick = jest.fn()
    render(<Button onClick={handleClick}>Click me</Button>)

    fireEvent.click(screen.getByRole('button'))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })
})

2. Use Data-Testids

// ❌ Bad: Fragile selectors
cy.get('.btn-primary').click() // Class names change
cy.get('button').contains('Submit') // Text changes

// ✅ Good: Stable selectors
cy.get('[data-testid="btn-submit"]').click() // Custom, stable
cy.get('[aria-label="Submit form"]').click() // Semantic

3. Avoid Test Coupling

// ❌ Bad: Tightly coupled to implementation
it('calls fetch with correct URL', async () => {
  const mockFetch = jest.fn().mockResolvedValue({ data: 'test' })
  global.fetch = mockFetch

  await useDataFetcher('/api/data')

  expect(mockFetch).toHaveBeenCalledWith('/api/data')
  expect(mockFetch).toHaveBeenCalledTimes(1)
  // Breaks if implementation changes
})

// ✅ Good: Testing behavior
it('returns data when fetch succeeds', async () => {
  global.fetch = jest.fn().mockResolvedValue({ data: 'test' })

  const { result } = renderHook(() => useDataFetcher('/api/data'))

  await waitFor(() => {
    expect(result.current.data).toBe('test')
  })
  // Works regardless of implementation
})

4. Write Maintainable Tests

// ❌ Bad: Repeated code
describe('Form', () => {
  it('validates name', () => {
    render(<Form />)
    fireEvent.change(screen.getByTestId('input-name'), 'ab')
    expect(screen.getByText('Name must be at least 3 characters')).toBeInTheDocument()
  })

  it('validates email', () => {
    render(<Form />)
    fireEvent.change(screen.getByTestId('input-email'), 'invalid')
    expect(screen.getByText('Invalid email')).toBeInTheDocument()
  })
})

// ✅ Good: Reusable helpers
describe('Form', () => {
  function fillForm(name: string, email: string) {
    render(<Form />)
    fireEvent.change(screen.getByTestId('input-name'), name)
    fireEvent.change(screen.getByTestId('input-email'), email)
  }

  function expectError(error: string) {
    expect(screen.getByText(error)).toBeInTheDocument()
  }

  it('validates name', () => {
    fillForm('ab', 'john@example.com')
    expectError('Name must be at least 3 characters')
  })

  it('validates email', () => {
    fillForm('John', 'invalid')
    expectError('Invalid email')
  })
})

Continuous Integration

GitHub Actions

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint

      - name: Run tests
        run: npm run test:ci

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

Conclusion

Testing React applications effectively requires understanding different testing strategies, using the right tools, and following best practices. By investing in comprehensive testing, you'll build more reliable, maintainable applications.

Key Takeaways

  1. Testing Pyramid - More unit tests, fewer E2E tests
  2. User Behavior - Test what users do, not how code works
  3. Data-Testids - Use stable, semantic selectors
  4. Mock Carefully - Mock only what's necessary
  5. Avoid Coupling - Tests should be independent of implementation
  6. Maintainability - Keep tests simple, readable, and maintainable
  7. CI/CD - Automate testing in your pipeline

Next Steps

  1. Set up your testing environment
  2. Write unit tests for components and hooks
  3. Add integration tests for key flows
  4. Implement E2E tests for critical user journeys
  5. Set up visual regression testing
  6. Integrate tests into CI/CD pipeline
  7. Monitor test coverage and improve over time

Testing is an investment that pays dividends in code quality and user confidence. Start testing today.


Ready to improve your testing practices? Start by adding tests to your next feature. Your future self will thank you.