Table of Contents
- Component Testing Principles
- Testing React Components
- User Interaction Testing
- Context and Provider Testing
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:Key Principles:
// ✅ 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()
})
})- 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:
- Query Priority: Role > Label > Text > TestId (use semantic queries first)
- User Events: Use
@testing-library/user-eventfor realistic interactions - Async Testing: Properly wait for async operations with
waitFor - Mock External Dependencies: Mock Next.js, APIs, and browser APIs
- 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()
})
})