UI Component Testing

Test React components, user interactions, and visual behavior using Testing Library in a jsdom environment.

Table of Contents

Component Testing Principles

UI component tests focus on behavior from the user's perspective:
Testing Library Philosophy: Test components as users would interact with them - through visible elements, not implementation details.
User-Centric Testing:
// ✅ Good: Test what users see and do
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Button } from '@/components/ui/button'

describe('Button Component', () => {
  it('should render button text', () => {
    render(<Button>Click me</Button>)

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

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

    fireEvent.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('should be disabled when loading', () => {
    render(<Button loading>Loading...</Button>)

    expect(screen.getByRole('button')).toBeDisabled()
    expect(screen.getByText('Loading...')).toBeInTheDocument()
  })
})
Key Principles:
  • Query by Role/Label: Use accessible queries that match user behavior
  • Test Behavior: Focus on what the component does, not how it works
  • User Interactions: Test clicking, typing, hovering as users would
  • Visual States: Test loading, disabled, error states that users see

Testing React Components

Test different types of React components commonly used in the SaaS application:
Form Input and Validation Testing:
// src/components/__tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from '@/components/auth/LoginForm'

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

  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('should render form fields', () => {
    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('should validate required fields', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    // Submit without filling fields
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    await waitFor(() => {
      expect(screen.getByText(/email is required/i)).toBeInTheDocument()
      expect(screen.getByText(/password is required/i)).toBeInTheDocument()
    })

    expect(mockOnSubmit).not.toHaveBeenCalled()
  })

  it('should validate email format', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'invalid-email')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    await waitFor(() => {
      expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
    })
  })

  it('should submit valid form', 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 }))

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

  it('should show loading state during submission', async () => {
    const user = userEvent.setup()
    render(<LoginForm onSubmit={mockOnSubmit} loading />)

    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
  })

  it('should display submission errors', async () => {
    render(<LoginForm onSubmit={mockOnSubmit} error="Invalid credentials" />)

    expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument()
    expect(screen.getByRole('alert')).toBeInTheDocument() // Error should be in alert
  })
})

// Complex form with multiple steps
describe('RegistrationForm', () => {
  it('should handle multi-step flow', async () => {
    const user = userEvent.setup()
    render(<RegistrationForm />)

    // Step 1: Basic info
    await user.type(screen.getByLabelText(/name/i), 'John Doe')
    await user.type(screen.getByLabelText(/email/i), 'john@example.com')
    await user.click(screen.getByRole('button', { name: /next/i }))

    // Step 2: Password
    await waitFor(() => {
      expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
    })

    await user.type(screen.getByLabelText(/password/i), 'SecurePass123!')
    await user.type(screen.getByLabelText(/confirm password/i), 'SecurePass123!')
    await user.click(screen.getByRole('button', { name: /create account/i }))

    // Verify submission
    await waitFor(() => {
      expect(screen.getByText(/account created successfully/i)).toBeInTheDocument()
    })
  })
})

User Interaction Testing

Test complex user interactions and workflows:
Accessibility and Keyboard Navigation:
// src/components/__tests__/AccessibleModal.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { Modal } from '@/components/ui/Modal'

describe('Modal Keyboard Navigation', () => {
  it('should trap focus within modal', async () => {
    const user = userEvent.setup()

    render(
      <Modal isOpen={true} onClose={vi.fn()}>
        <input aria-label="First input" />
        <button>Action</button>
        <input aria-label="Last input" />
      </Modal>
    )

    const firstInput = screen.getByLabelText('First input')
    const button = screen.getByRole('button', { name: 'Action' })
    const lastInput = screen.getByLabelText('Last input')

    // Focus should start on first focusable element
    expect(firstInput).toHaveFocus()

    // Tab to next element
    await user.tab()
    expect(button).toHaveFocus()

    // Tab to last element
    await user.tab()
    expect(lastInput).toHaveFocus()

    // Tab should wrap to first element
    await user.tab()
    expect(firstInput).toHaveFocus()

    // Shift+Tab should go backward
    await user.tab({ shift: true })
    expect(lastInput).toHaveFocus()
  })

  it('should close on Escape key', async () => {
    const user = userEvent.setup()
    const mockOnClose = vi.fn()

    render(
      <Modal isOpen={true} onClose={mockOnClose}>
        <div>Modal content</div>
      </Modal>
    )

    await user.keyboard('{Escape}')
    expect(mockOnClose).toHaveBeenCalled()
  })

  it('should handle Enter key on buttons', async () => {
    const user = userEvent.setup()
    const mockAction = vi.fn()

    render(
      <Modal isOpen={true} onClose={vi.fn()}>
        <button onClick={mockAction}>Submit</button>
      </Modal>
    )

    screen.getByRole('button', { name: 'Submit' }).focus()
    await user.keyboard('{Enter}')
    expect(mockAction).toHaveBeenCalled()
  })
})

// Form keyboard navigation
describe('Form Keyboard Navigation', () => {
  it('should navigate between form fields', async () => {
    const user = userEvent.setup()

    render(
      <form>
        <input aria-label="Name" />
        <input aria-label="Email" />
        <select aria-label="Country">
          <option value="us">United States</option>
          <option value="ca">Canada</option>
        </select>
        <button type="submit">Submit</button>
      </form>
    )

    const nameInput = screen.getByLabelText('Name')
    const emailInput = screen.getByLabelText('Email')
    const countrySelect = screen.getByLabelText('Country')
    const submitButton = screen.getByRole('button', { name: 'Submit' })

    // Tab through form fields
    await user.tab()
    expect(nameInput).toHaveFocus()

    await user.tab()
    expect(emailInput).toHaveFocus()

    await user.tab()
    expect(countrySelect).toHaveFocus()

    await user.tab()
    expect(submitButton).toHaveFocus()
  })
})

Context and Provider Testing

Test React Context providers and complex component trees:
Testing Components with Context:
// src/app/__tests__/theme.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect } from 'vitest'
import { ThemeProvider, useTheme } from '@/components/theme-provider'

// Test component that uses theme context
const ThemeConsumer = () => {
  const { theme, setTheme } = useTheme()
  return (
    <div>
      <span>Current theme: {theme}</span>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle theme
      </button>
    </div>
  )
}

describe('ThemeProvider', () => {
  it('should provide default theme', () => {
    render(
      <ThemeProvider defaultTheme="light">
        <ThemeConsumer />
      </ThemeProvider>
    )

    expect(screen.getByText('Current theme: light')).toBeInTheDocument()
  })

  it('should allow theme changes', async () => {
    const user = userEvent.setup()

    render(
      <ThemeProvider defaultTheme="light">
        <ThemeConsumer />
      </ThemeProvider>
    )

    expect(screen.getByText('Current theme: light')).toBeInTheDocument()

    await user.click(screen.getByRole('button', { name: /toggle theme/i }))

    expect(screen.getByText('Current theme: dark')).toBeInTheDocument()
  })

  it('should apply theme class to document', () => {
    render(
      <ThemeProvider defaultTheme="dark">
        <ThemeConsumer />
      </ThemeProvider>
    )

    expect(document.documentElement).toHaveClass('dark')
  })

  it('should persist theme preference', async () => {
    const user = userEvent.setup()

    // Mock localStorage
    const mockSetItem = vi.fn()
    const mockGetItem = vi.fn(() => 'dark')
    Object.defineProperty(window, 'localStorage', {
      value: { getItem: mockGetItem, setItem: mockSetItem },
    })

    render(
      <ThemeProvider>
        <ThemeConsumer />
      </ThemeProvider>
    )

    // Should load from localStorage
    expect(screen.getByText('Current theme: dark')).toBeInTheDocument()

    // Should save to localStorage on change
    await user.click(screen.getByRole('button', { name: /toggle theme/i }))
    expect(mockSetItem).toHaveBeenCalledWith('theme', 'light')
  })
})

// Custom render helper for theme testing
const renderWithTheme = (
  component: React.ReactElement,
  { theme = 'light' } = {}
) => {
  return render(
    <ThemeProvider defaultTheme={theme}>
      {component}
    </ThemeProvider>
  )
}

describe('Components with Theme', () => {
  it('should render with light theme styles', () => {
    renderWithTheme(<Button variant="primary">Click me</Button>)

    const button = screen.getByRole('button')
    expect(button).toHaveClass('bg-white', 'text-black')
  })

  it('should render with dark theme styles', () => {
    renderWithTheme(
      <Button variant="primary">Click me</Button>,
      { theme: 'dark' }
    )

    const button = screen.getByRole('button')
    expect(button).toHaveClass('bg-gray-900', 'text-white')
  })
})

Best Practices Summary

Component Testing Excellence:
  • User-Centric: Test components as users would interact with them
  • Accessible Queries: Use semantic queries that match accessibility patterns
  • Realistic Scenarios: Test real user workflows and edge cases
  • Provider Testing: Test components within their context providers

Key Principles:

  1. Query Priority: Role > Label > Text > TestId (use semantic queries first)
  2. User Events: Use @testing-library/user-event for realistic interactions
  3. Async Testing: Properly wait for async operations with waitFor
  4. Mock External Dependencies: Mock Next.js, APIs, and browser APIs
  5. Test Accessibility: Ensure components work with keyboard navigation

Common Patterns:

// ✅ Good component test structure
describe('ComponentName', () => {
  it('should handle user interaction', async () => {
    const user = userEvent.setup()
    const mockHandler = vi.fn()

    render(<Component onAction={mockHandler} />)

    await user.click(screen.getByRole('button', { name: /action/i }))

    expect(mockHandler).toHaveBeenCalled()
  })
})
Ready to test your database layer? Continue with Repository Testing to learn about testing database operations and data integrity.
    UI Component Testing | ShipSaaS Documentation | ShipSaaS - Launch your SaaS with AI in days