Published on

Comprehensive Frontend Unit Testing Guide: From Components to API Integration

Unit testing is the foundation of reliable frontend applications. Yet many developers struggle with what to test, how to test it, and how to make tests maintainable. Today, we'll dive deep into comprehensive frontend unit testing, covering everything from simple components to complex API integrations.

Why Frontend Unit Testing Matters

Frontend applications are increasingly complex, with intricate state management, user interactions, and API dependencies. Without proper testing:

  • Bugs slip through to production
  • Refactoring becomes risky and slow
  • Team confidence decreases with each release
  • Technical debt accumulates rapidly

The solution? A robust testing strategy that gives you confidence to ship fast and break nothing.

๐Ÿงช Testing Philosophy and Strategy

The Testing Pyramid for Frontend

           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
           โ”‚   E2E Tests     โ”‚  โ† Few, Expensive, Slow
           โ”‚   (Cypress)     โ”‚
           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
         โ”‚  Integration Tests    โ”‚  โ† Some, Moderate Cost
         โ”‚ (Component + API)     โ”‚
         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
       โ”‚      Unit Tests             โ”‚  โ† Many, Cheap, Fast
       โ”‚ (Functions, Components)     โ”‚
       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

What to Test at Each Level

Test TypeScopeToolsExamples
UnitIndividual functions/componentsJest, React Testing LibraryPure functions, component rendering, user interactions
IntegrationMultiple units working togetherJest + MSWComponent + API, form submission flows
E2EComplete user workflowsCypress, PlaywrightLogin flow, checkout process

๐Ÿ› ๏ธ Essential Testing Tools Setup

Core Testing Stack

# Core testing framework
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

# API mocking
npm install --save-dev msw

# Additional utilities
npm install --save-dev jest-environment-jsdom

Jest Configuration

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/index.js',
    '!src/reportWebVitals.js',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  testMatch: [
    '<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
    '<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}',
  ],
}

Test Setup File

// src/setupTests.js
import '@testing-library/jest-dom'
import { server } from './mocks/server'

// Establish API mocking before all tests
beforeAll(() => server.listen())

// Reset any request handlers that we may add during the tests
afterEach(() => server.resetHandlers())

// Clean up after the tests are finished
afterAll(() => server.close())

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  observe() {}
  unobserve() {}
  disconnect() {}
}

// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
  constructor() {}
  observe() {}
  unobserve() {}
  disconnect() {}
}

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
})

๐Ÿงฉ Component Testing Strategies

1. Basic Component Testing

// components/Button/Button.jsx
import React from 'react'
import classNames from 'classnames'

const Button = ({
  children,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
  ...props
}) => {
  const buttonClasses = classNames('btn', {
    [`btn--${variant}`]: variant,
    [`btn--${size}`]: size,
    'btn--disabled': disabled,
  })

  return (
    <button className={buttonClasses} disabled={disabled} onClick={onClick} {...props}>
      {children}
    </button>
  )
}

export default Button
// components/Button/Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Button from './Button'

describe('Button Component', () => {
  it('renders with default props', () => {
    render(<Button>Click me</Button>)

    const button = screen.getByRole('button', { name: /click me/i })
    expect(button).toBeInTheDocument()
    expect(button).toHaveClass('btn', 'btn--primary', 'btn--medium')
  })

  it('applies variant classes correctly', () => {
    render(<Button variant="secondary">Secondary</Button>)

    const button = screen.getByRole('button')
    expect(button).toHaveClass('btn--secondary')
  })

  it('applies size classes correctly', () => {
    render(<Button size="large">Large Button</Button>)

    const button = screen.getByRole('button')
    expect(button).toHaveClass('btn--large')
  })

  it('handles disabled state', () => {
    render(<Button disabled>Disabled</Button>)

    const button = screen.getByRole('button')
    expect(button).toBeDisabled()
    expect(button).toHaveClass('btn--disabled')
  })

  it('calls onClick when clicked', async () => {
    const user = userEvent.setup()
    const handleClick = jest.fn()

    render(<Button onClick={handleClick}>Click me</Button>)

    const button = screen.getByRole('button')
    await user.click(button)

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('does not call onClick when disabled', async () => {
    const user = userEvent.setup()
    const handleClick = jest.fn()

    render(
      <Button onClick={handleClick} disabled>
        Disabled
      </Button>
    )

    const button = screen.getByRole('button')
    await user.click(button)

    expect(handleClick).not.toHaveBeenCalled()
  })

  it('forwards additional props', () => {
    render(
      <Button data-testid="custom-button" aria-label="Custom">
        Test
      </Button>
    )

    const button = screen.getByTestId('custom-button')
    expect(button).toHaveAttribute('aria-label', 'Custom')
  })
})

2. Form Component Testing

// components/LoginForm/LoginForm.jsx
import React, { useState } from 'react'
import Button from '../Button/Button'

const LoginForm = ({ onSubmit, isLoading = false }) => {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  })
  const [errors, setErrors] = useState({})

  const validateForm = () => {
    const newErrors = {}

    if (!formData.email) {
      newErrors.email = 'Email is required'
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email is invalid'
    }

    if (!formData.password) {
      newErrors.password = 'Password is required'
    } else if (formData.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = (e) => {
    e.preventDefault()

    if (validateForm()) {
      onSubmit(formData)
    }
  }

  const handleChange = (e) => {
    const { name, value } = e.target
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }))

    // Clear error when user starts typing
    if (errors[name]) {
      setErrors((prev) => ({
        ...prev,
        [name]: '',
      }))
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <div id="email-error" className="error" role="alert">
            {errors.email}
          </div>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <div id="password-error" className="error" role="alert">
            {errors.password}
          </div>
        )}
      </div>

      <Button type="submit" disabled={isLoading}>
        {isLoading ? 'Signing in...' : 'Sign In'}
      </Button>
    </form>
  )
}

export default LoginForm
// components/LoginForm/LoginForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'

describe('LoginForm', () => {
  const mockOnSubmit = jest.fn()

  beforeEach(() => {
    mockOnSubmit.mockClear()
  })

  it('renders form fields correctly', () => {
    render(<LoginForm onSubmit={mockOnSubmit} />)

    expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
  })

  it('updates input values when user types', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    const emailInput = screen.getByLabelText(/email/i)
    const passwordInput = screen.getByLabelText(/password/i)

    await user.type(emailInput, 'test@example.com')
    await user.type(passwordInput, 'password123')

    expect(emailInput).toHaveValue('test@example.com')
    expect(passwordInput).toHaveValue('password123')
  })

  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

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

    expect(screen.getByText('Email is required')).toBeInTheDocument()
    expect(screen.getByText('Password is required')).toBeInTheDocument()
    expect(mockOnSubmit).not.toHaveBeenCalled()
  })

  it('shows validation error for invalid email', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    const emailInput = screen.getByLabelText(/email/i)
    const submitButton = screen.getByRole('button', { name: /sign in/i })

    await user.type(emailInput, 'invalid-email')
    await user.click(submitButton)

    expect(screen.getByText('Email is invalid')).toBeInTheDocument()
  })

  it('shows validation error for short password', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    const passwordInput = screen.getByLabelText(/password/i)
    const submitButton = screen.getByRole('button', { name: /sign in/i })

    await user.type(passwordInput, '123')
    await user.click(submitButton)

    expect(screen.getByText('Password must be at least 6 characters')).toBeInTheDocument()
  })

  it('clears errors when user starts typing', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    const emailInput = screen.getByLabelText(/email/i)
    const submitButton = screen.getByRole('button', { name: /sign in/i })

    // Trigger validation error
    await user.click(submitButton)
    expect(screen.getByText('Email is required')).toBeInTheDocument()

    // Start typing to clear error
    await user.type(emailInput, 'test@example.com')
    expect(screen.queryByText('Email is required')).not.toBeInTheDocument()
  })

  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

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

    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    })
  })

  it('shows loading state correctly', () => {
    render(<LoginForm onSubmit={mockOnSubmit} isLoading={true} />)

    const submitButton = screen.getByRole('button')
    expect(submitButton).toHaveTextContent('Signing in...')
    expect(submitButton).toBeDisabled()
  })

  it('handles form submission with Enter key', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    const emailInput = screen.getByLabelText(/email/i)

    await user.type(emailInput, 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.keyboard('{Enter}')

    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    })
  })
})

3. Component with Hooks Testing

// hooks/useApi.js
import { useState, useEffect } from 'react'

const useApi = (url, options = {}) => {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false

    const fetchData = async () => {
      try {
        setLoading(true)
        setError(null)

        const response = await fetch(url, options)

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }

        const result = await response.json()

        if (!cancelled) {
          setData(result)
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message)
        }
      } finally {
        if (!cancelled) {
          setLoading(false)
        }
      }
    }

    fetchData()

    return () => {
      cancelled = true
    }
  }, [url, options])

  return { data, loading, error }
}

export default useApi
// components/UserProfile/UserProfile.jsx
import React from 'react'
import useApi from '../../hooks/useApi'

const UserProfile = ({ userId }) => {
  const { data: user, loading, error } = useApi(`/api/users/${userId}`)

  if (loading) {
    return <div data-testid="loading">Loading user profile...</div>
  }

  if (error) {
    return (
      <div data-testid="error" role="alert">
        Error loading user: {error}
      </div>
    )
  }

  if (!user) {
    return <div data-testid="no-user">User not found</div>
  }

  return (
    <div data-testid="user-profile">
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
      <p>Role: {user.role}</p>
      {user.avatar && (
        <img src={user.avatar} alt={`${user.name}'s avatar`} width="100" height="100" />
      )}
    </div>
  )
}

export default UserProfile
// components/UserProfile/UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react'
import { rest } from 'msw'
import { server } from '../../mocks/server'
import UserProfile from './UserProfile'

const mockUser = {
  id: '1',
  name: 'John Doe',
  email: 'john@example.com',
  role: 'admin',
  avatar: 'https://example.com/avatar.jpg',
}

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    render(<UserProfile userId="1" />)
    expect(screen.getByTestId('loading')).toBeInTheDocument()
  })

  it('displays user data when loaded successfully', async () => {
    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res(ctx.json(mockUser))
      })
    )

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument()
    })

    expect(screen.getByText('John Doe')).toBeInTheDocument()
    expect(screen.getByText('Email: john@example.com')).toBeInTheDocument()
    expect(screen.getByText('Role: admin')).toBeInTheDocument()

    const avatar = screen.getByAltText("John Doe's avatar")
    expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg')
  })

  it('displays error message when API call fails', async () => {
    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: 'Server error' }))
      })
    )

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })

    expect(screen.getByText(/error loading user/i)).toBeInTheDocument()
  })

  it('displays not found message when user does not exist', async () => {
    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res(ctx.status(404), ctx.json({ message: 'User not found' }))
      })
    )

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })
  })

  it('handles network errors gracefully', async () => {
    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res.networkError('Failed to connect')
      })
    )

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })
  })

  it('does not render avatar when not provided', async () => {
    const userWithoutAvatar = { ...mockUser, avatar: null }

    server.use(
      rest.get('/api/users/1', (req, res, ctx) => {
        return res(ctx.json(userWithoutAvatar))
      })
    )

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByTestId('user-profile')).toBeInTheDocument()
    })

    expect(screen.queryByRole('img')).not.toBeInTheDocument()
  })
})

๐ŸŒ API Testing Strategies

MSW (Mock Service Worker) Setup

// src/mocks/handlers.js
import { rest } from 'msw'

export const handlers = [
  // User endpoints
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params

    const users = {
      1: {
        id: '1',
        name: 'John Doe',
        email: 'john@example.com',
        role: 'admin',
      },
      2: {
        id: '2',
        name: 'Jane Smith',
        email: 'jane@example.com',
        role: 'user',
      },
    }

    const user = users[id]

    if (!user) {
      return res(ctx.status(404), ctx.json({ message: 'User not found' }))
    }

    return res(ctx.json(user))
  }),

  // Authentication endpoints
  rest.post('/api/auth/login', async (req, res, ctx) => {
    const { email, password } = await req.json()

    // Simulate validation
    if (email === 'test@example.com' && password === 'password123') {
      return res(
        ctx.json({
          user: {
            id: '1',
            name: 'Test User',
            email: 'test@example.com',
          },
          token: 'mock-jwt-token',
        })
      )
    }

    return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' }))
  }),

  // Products endpoint with pagination
  rest.get('/api/products', (req, res, ctx) => {
    const page = parseInt(req.url.searchParams.get('page') || '1')
    const limit = parseInt(req.url.searchParams.get('limit') || '10')
    const category = req.url.searchParams.get('category')

    let products = [
      { id: '1', name: 'Product 1', category: 'electronics', price: 99.99 },
      { id: '2', name: 'Product 2', category: 'clothing', price: 49.99 },
      { id: '3', name: 'Product 3', category: 'electronics', price: 199.99 },
      // ... more products
    ]

    // Filter by category if provided
    if (category) {
      products = products.filter((p) => p.category === category)
    }

    // Simulate pagination
    const startIndex = (page - 1) * limit
    const endIndex = startIndex + limit
    const paginatedProducts = products.slice(startIndex, endIndex)

    return res(
      ctx.json({
        products: paginatedProducts,
        pagination: {
          page,
          limit,
          total: products.length,
          totalPages: Math.ceil(products.length / limit),
        },
      })
    )
  }),

  // Error simulation endpoint
  rest.get('/api/error', (req, res, ctx) => {
    return res(ctx.status(500), ctx.json({ message: 'Internal server error' }))
  }),
]
// src/mocks/server.js
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Service Layer Testing

// services/api.js
const API_BASE_URL = process.env.REACT_APP_API_URL || '/api'

class ApiError extends Error {
  constructor(message, status, data) {
    super(message)
    this.name = 'ApiError'
    this.status = status
    this.data = data
  }
}

const apiClient = {
  async request(endpoint, options = {}) {
    const url = `${API_BASE_URL}${endpoint}`

    const config = {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
      ...options,
    }

    const response = await fetch(url, config)

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}))
      throw new ApiError(errorData.message || 'Something went wrong', response.status, errorData)
    }

    return response.json()
  },

  get(endpoint, options = {}) {
    return this.request(endpoint, { method: 'GET', ...options })
  },

  post(endpoint, data, options = {}) {
    return this.request(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
      ...options,
    })
  },

  put(endpoint, data, options = {}) {
    return this.request(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
      ...options,
    })
  },

  delete(endpoint, options = {}) {
    return this.request(endpoint, { method: 'DELETE', ...options })
  },
}

export { apiClient, ApiError }
// services/userService.js
import { apiClient } from './api'

export const userService = {
  async getUser(id) {
    return apiClient.get(`/users/${id}`)
  },

  async getCurrentUser() {
    return apiClient.get('/users/me')
  },

  async updateUser(id, userData) {
    return apiClient.put(`/users/${id}`, userData)
  },

  async deleteUser(id) {
    return apiClient.delete(`/users/${id}`)
  },
}
// services/userService.test.js
import { rest } from 'msw'
import { server } from '../mocks/server'
import { userService } from './userService'
import { ApiError } from './api'

describe('userService', () => {
  describe('getUser', () => {
    it('returns user data for valid ID', async () => {
      const mockUser = {
        id: '1',
        name: 'John Doe',
        email: 'john@example.com',
      }

      server.use(
        rest.get('/api/users/1', (req, res, ctx) => {
          return res(ctx.json(mockUser))
        })
      )

      const user = await userService.getUser('1')
      expect(user).toEqual(mockUser)
    })

    it('throws ApiError for non-existent user', async () => {
      server.use(
        rest.get('/api/users/999', (req, res, ctx) => {
          return res(ctx.status(404), ctx.json({ message: 'User not found' }))
        })
      )

      await expect(userService.getUser('999')).rejects.toThrow(ApiError)

      try {
        await userService.getUser('999')
      } catch (error) {
        expect(error.status).toBe(404)
        expect(error.message).toBe('User not found')
      }
    })

    it('throws ApiError for server errors', async () => {
      server.use(
        rest.get('/api/users/1', (req, res, ctx) => {
          return res(ctx.status(500))
        })
      )

      await expect(userService.getUser('1')).rejects.toThrow(ApiError)
    })
  })

  describe('updateUser', () => {
    it('updates user successfully', async () => {
      const updatedUser = {
        id: '1',
        name: 'John Updated',
        email: 'john.updated@example.com',
      }

      server.use(
        rest.put('/api/users/1', (req, res, ctx) => {
          return res(ctx.json(updatedUser))
        })
      )

      const result = await userService.updateUser('1', {
        name: 'John Updated',
        email: 'john.updated@example.com',
      })

      expect(result).toEqual(updatedUser)
    })

    it('handles validation errors', async () => {
      server.use(
        rest.put('/api/users/1', (req, res, ctx) => {
          return res(
            ctx.status(400),
            ctx.json({
              message: 'Validation failed',
              errors: {
                email: 'Invalid email format',
              },
            })
          )
        })
      )

      try {
        await userService.updateUser('1', { email: 'invalid-email' })
      } catch (error) {
        expect(error).toBeInstanceOf(ApiError)
        expect(error.status).toBe(400)
        expect(error.data.errors.email).toBe('Invalid email format')
      }
    })
  })
})

Integration Testing with Components and APIs

// components/UserList/UserList.jsx
import React, { useEffect, useState } from 'react'
import { userService } from '../../services/userService'

const UserList = () => {
  const [users, setUsers] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true)
        const userData = await userService.getUsers()
        setUsers(userData)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchUsers()
  }, [])

  const handleDeleteUser = async (userId) => {
    try {
      await userService.deleteUser(userId)
      setUsers((prev) => prev.filter((user) => user.id !== userId))
    } catch (err) {
      setError(`Failed to delete user: ${err.message}`)
    }
  }

  if (loading) return <div data-testid="loading">Loading users...</div>
  if (error) return <div data-testid="error">Error: {error}</div>

  return (
    <div data-testid="user-list">
      <h2>Users</h2>
      {users.length === 0 ? (
        <p data-testid="no-users">No users found</p>
      ) : (
        <ul>
          {users.map((user) => (
            <li key={user.id} data-testid={`user-${user.id}`}>
              <span>
                {user.name} ({user.email})
              </span>
              <button onClick={() => handleDeleteUser(user.id)} aria-label={`Delete ${user.name}`}>
                Delete
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export default UserList
// components/UserList/UserList.test.jsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { server } from '../../mocks/server'
import UserList from './UserList'

const mockUsers = [
  { id: '1', name: 'John Doe', email: 'john@example.com' },
  { id: '2', name: 'Jane Smith', email: 'jane@example.com' },
]

describe('UserList Integration', () => {
  beforeEach(() => {
    // Setup default successful response
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.json(mockUsers))
      })
    )
  })

  it('fetches and displays users on mount', async () => {
    render(<UserList />)

    // Shows loading initially
    expect(screen.getByTestId('loading')).toBeInTheDocument()

    // Wait for users to load
    await waitFor(() => {
      expect(screen.getByTestId('user-list')).toBeInTheDocument()
    })

    // Check users are displayed
    expect(screen.getByTestId('user-1')).toBeInTheDocument()
    expect(screen.getByTestId('user-2')).toBeInTheDocument()
    expect(screen.getByText('John Doe (john@example.com)')).toBeInTheDocument()
    expect(screen.getByText('Jane Smith (jane@example.com)')).toBeInTheDocument()
  })

  it('handles API errors gracefully', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: 'Server error' }))
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument()
    })

    expect(screen.getByText(/error: server error/i)).toBeInTheDocument()
  })

  it('deletes user successfully', async () => {
    const user = userEvent.setup()

    // Mock successful delete
    server.use(
      rest.delete('/api/users/1', (req, res, ctx) => {
        return res(ctx.status(204))
      })
    )

    render(<UserList />)

    // Wait for initial load
    await waitFor(() => {
      expect(screen.getByTestId('user-1')).toBeInTheDocument()
    })

    // Click delete button
    const deleteButton = screen.getByLabelText('Delete John Doe')
    await user.click(deleteButton)

    // User should be removed from the list
    await waitFor(() => {
      expect(screen.queryByTestId('user-1')).not.toBeInTheDocument()
    })

    // Other user should still be there
    expect(screen.getByTestId('user-2')).toBeInTheDocument()
  })

  it('handles delete errors', async () => {
    const user = userEvent.setup()

    server.use(
      rest.delete('/api/users/1', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: 'Delete failed' }))
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByTestId('user-1')).toBeInTheDocument()
    })

    const deleteButton = screen.getByLabelText('Delete John Doe')
    await user.click(deleteButton)

    // Error should be displayed
    await waitFor(() => {
      expect(screen.getByText(/failed to delete user: delete failed/i)).toBeInTheDocument()
    })

    // User should still be in the list
    expect(screen.getByTestId('user-1')).toBeInTheDocument()
  })

  it('shows no users message when list is empty', async () => {
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        return res(ctx.json([]))
      })
    )

    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByTestId('no-users')).toBeInTheDocument()
    })

    expect(screen.getByText('No users found')).toBeInTheDocument()
  })
})

๐ŸŽญ Advanced Testing Patterns

Custom Render Function

// test-utils.js
import React from 'react'
import { render } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from 'react-query'
import { AuthProvider } from '../contexts/AuthContext'
import { ThemeProvider } from '../contexts/ThemeContext'

const AllProviders = ({ children, initialEntries = ['/'] }) => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  })

  return (
    <BrowserRouter>
      <QueryClientProvider client={queryClient}>
        <AuthProvider>
          <ThemeProvider>{children}</ThemeProvider>
        </AuthProvider>
      </QueryClientProvider>
    </BrowserRouter>
  )
}

const customRender = (ui, options = {}) => render(ui, { wrapper: AllProviders, ...options })

// Re-export everything
export * from '@testing-library/react'
export { customRender as render }

Testing Context Providers

// contexts/AuthContext.jsx
import React, { createContext, useContext, useReducer } from 'react'

const AuthContext = createContext()

const authReducer = (state, action) => {
  switch (action.type) {
    case 'LOGIN_START':
      return { ...state, loading: true, error: null }
    case 'LOGIN_SUCCESS':
      return { ...state, loading: false, user: action.payload, isAuthenticated: true }
    case 'LOGIN_FAILURE':
      return { ...state, loading: false, error: action.payload }
    case 'LOGOUT':
      return { loading: false, user: null, isAuthenticated: false, error: null }
    default:
      return state
  }
}

export const AuthProvider = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isAuthenticated: false,
    loading: false,
    error: null,
  })

  const login = async (credentials) => {
    dispatch({ type: 'LOGIN_START' })
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      })

      if (!response.ok) {
        throw new Error('Login failed')
      }

      const data = await response.json()
      dispatch({ type: 'LOGIN_SUCCESS', payload: data.user })
    } catch (error) {
      dispatch({ type: 'LOGIN_FAILURE', payload: error.message })
    }
  }

  const logout = () => {
    dispatch({ type: 'LOGOUT' })
  }

  return <AuthContext.Provider value={{ ...state, login, logout }}>{children}</AuthContext.Provider>
}

export const useAuth = () => {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}
// contexts/AuthContext.test.jsx
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { rest } from 'msw'
import { server } from '../mocks/server'
import { AuthProvider, useAuth } from './AuthContext'

// Test component that uses the auth context
const TestComponent = () => {
  const { user, isAuthenticated, loading, error, login, logout } = useAuth()

  return (
    <div>
      <div data-testid="auth-status">
        {loading && 'Loading...'}
        {isAuthenticated ? `Logged in as ${user?.name}` : 'Not logged in'}
        {error && `Error: ${error}`}
      </div>
      <button onClick={() => login({ email: 'test@example.com', password: 'password' })}>
        Login
      </button>
      <button onClick={logout}>Logout</button>
    </div>
  )
}

const renderWithAuthProvider = (component) => {
  return render(<AuthProvider>{component}</AuthProvider>)
}

describe('AuthContext', () => {
  it('starts with unauthenticated state', () => {
    renderWithAuthProvider(<TestComponent />)

    expect(screen.getByTestId('auth-status')).toHaveTextContent('Not logged in')
  })

  it('handles successful login', async () => {
    const user = userEvent.setup()

    server.use(
      rest.post('/api/auth/login', (req, res, ctx) => {
        return res(
          ctx.json({
            user: { id: '1', name: 'Test User', email: 'test@example.com' },
          })
        )
      })
    )

    renderWithAuthProvider(<TestComponent />)

    await user.click(screen.getByText('Login'))

    // Should show loading state
    expect(screen.getByTestId('auth-status')).toHaveTextContent('Loading...')

    // Wait for login to complete
    await waitFor(() => {
      expect(screen.getByTestId('auth-status')).toHaveTextContent('Logged in as Test User')
    })
  })

  it('handles login failure', async () => {
    const user = userEvent.setup()

    server.use(
      rest.post('/api/auth/login', (req, res, ctx) => {
        return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' }))
      })
    )

    renderWithAuthProvider(<TestComponent />)

    await user.click(screen.getByText('Login'))

    await waitFor(() => {
      expect(screen.getByTestId('auth-status')).toHaveTextContent('Error: Login failed')
    })
  })

  it('handles logout', async () => {
    const user = userEvent.setup()

    // Set up successful login first
    server.use(
      rest.post('/api/auth/login', (req, res, ctx) => {
        return res(
          ctx.json({
            user: { id: '1', name: 'Test User', email: 'test@example.com' },
          })
        )
      })
    )

    renderWithAuthProvider(<TestComponent />)

    // Login first
    await user.click(screen.getByText('Login'))
    await waitFor(() => {
      expect(screen.getByTestId('auth-status')).toHaveTextContent('Logged in as Test User')
    })

    // Then logout
    await user.click(screen.getByText('Logout'))
    expect(screen.getByTestId('auth-status')).toHaveTextContent('Not logged in')
  })

  it('throws error when useAuth is used outside provider', () => {
    // Suppress console.error for this test
    const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})

    const TestComponentWithoutProvider = () => {
      useAuth() // This should throw
      return <div>Should not render</div>
    }

    expect(() => {
      render(<TestComponentWithoutProvider />)
    }).toThrow('useAuth must be used within AuthProvider')

    consoleSpy.mockRestore()
  })
})

๐Ÿ“Š Testing Metrics and Coverage

Coverage Configuration

// jest.config.js - Enhanced coverage settings
module.exports = {
  // ... other config
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/index.js',
    '!src/reportWebVitals.js',
    '!src/**/*.stories.{js,jsx,ts,tsx}',
    '!src/**/*.test.{js,jsx,ts,tsx}',
    '!src/mocks/**',
    '!src/test-utils.js',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
    // Stricter requirements for critical paths
    './src/services/': {
      branches: 90,
      functions: 90,
      lines: 90,
      statements: 90,
    },
    './src/components/': {
      branches: 85,
      functions: 85,
      lines: 85,
      statements: 85,
    },
  },
  coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
}

Test Organization

src/
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ Button/
โ”‚   โ”‚   โ”œโ”€โ”€ Button.jsx
โ”‚   โ”‚   โ”œโ”€โ”€ Button.test.jsx
โ”‚   โ”‚   โ””โ”€โ”€ Button.stories.jsx
โ”‚   โ””โ”€โ”€ LoginForm/
โ”‚       โ”œโ”€โ”€ LoginForm.jsx
โ”‚       โ”œโ”€โ”€ LoginForm.test.jsx
โ”‚       โ””โ”€โ”€ LoginForm.stories.jsx
โ”œโ”€โ”€ hooks/
โ”‚   โ”œโ”€โ”€ useApi.js
โ”‚   โ””โ”€โ”€ useApi.test.js
โ”œโ”€โ”€ services/
โ”‚   โ”œโ”€โ”€ api.js
โ”‚   โ”œโ”€โ”€ userService.js
โ”‚   โ””โ”€โ”€ __tests__/
โ”‚       โ”œโ”€โ”€ api.test.js
โ”‚       โ””โ”€โ”€ userService.test.js
โ”œโ”€โ”€ mocks/
โ”‚   โ”œโ”€โ”€ handlers.js
โ”‚   โ””โ”€โ”€ server.js
โ”œโ”€โ”€ test-utils.js
โ””โ”€โ”€ setupTests.js

๐Ÿ† Best Practices and Guidelines

Testing Philosophy

โœ… DO:

  • Test behavior, not implementation
  • Write tests before fixing bugs
  • Keep tests simple and focused
  • Use descriptive test names
  • Mock external dependencies
  • Test edge cases and error scenarios

โŒ DON'T:

  • Test internal component state directly
  • Mock everything (over-mocking)
  • Write tests that depend on other tests
  • Test third-party libraries
  • Ignore failing tests
  • Write overly complex test setups

Naming Conventions

// โœ… Good: Descriptive test names
describe('LoginForm', () => {
  it('shows validation error when email is empty', () => {})
  it('calls onSubmit with form data when validation passes', () => {})
  it('disables submit button when loading', () => {})
})

// โŒ Bad: Vague test names
describe('LoginForm', () => {
  it('works correctly', () => {})
  it('handles error', () => {})
  it('tests form', () => {})
})

Effective Assertions

// โœ… Good: Specific assertions
expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled()
expect(mockOnSubmit).toHaveBeenCalledWith({
  email: 'test@example.com',
  password: 'password123',
})

// โŒ Bad: Generic assertions
expect(component).toBeTruthy()
expect(mockFunction).toHaveBeenCalled() // Missing specific call verification

Test Data Management

// test-data.js
export const testData = {
  users: {
    admin: {
      id: '1',
      name: 'Admin User',
      email: 'admin@example.com',
      role: 'admin',
    },
    regularUser: {
      id: '2',
      name: 'Regular User',
      email: 'user@example.com',
      role: 'user',
    },
  },

  products: {
    electronics: [
      { id: '1', name: 'Laptop', category: 'electronics', price: 999.99 },
      { id: '2', name: 'Phone', category: 'electronics', price: 599.99 },
    ],
  },
}

// Factory functions for dynamic test data
export const createUser = (overrides = {}) => ({
  id: Math.random().toString(),
  name: 'Test User',
  email: 'test@example.com',
  role: 'user',
  ...overrides,
})

๐Ÿš€ Continuous Integration

GitHub Actions Workflow

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.x, 18.x]

    steps:
      - uses: actions/checkout@v3

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run type check
        run: npm run type-check

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

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

      - name: Comment coverage on PR
        if: github.event_name == 'pull_request'
        uses: romeovs/lcov-reporter-action@v0.3.1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          lcov-file: ./coverage/lcov.info

Package.json Scripts

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:debug": "node --inspect-brk scripts/test.js --runInBand --no-cache",
    "test:ci": "jest --coverage --ci --reporters=default --reporters=jest-junit",
    "test:update-snapshots": "jest --updateSnapshot"
  }
}

Conclusion

Frontend unit testing is essential for building reliable, maintainable applications. With the right tools and strategies, you can create a comprehensive testing suite that:

  • Catches bugs early before they reach production
  • Provides confidence for refactoring and new features
  • Documents expected behavior for other developers
  • Enables faster development through rapid feedback

Key Takeaways

  1. Start with the testing pyramid - Many unit tests, some integration tests, few E2E tests
  2. Test behavior, not implementation - Focus on what users experience
  3. Use MSW for API mocking - Realistic API testing without backend dependency
  4. Organize tests logically - Keep tests close to the code they test
  5. Maintain good coverage - Aim for 80%+ coverage on critical paths
  6. Automate everything - Run tests on every commit and PR

Remember: Good tests are an investment in your codebase's future. They pay dividends in reduced bugs, faster development, and team confidence.

Happy testing! ๐Ÿงชโœจ