TestingReact Testing

React Testing with React Testing Library

Learn how to test React components effectively using React Testing Library, focusing on testing behavior from a user’s perspective.

Philosophy of Testing User Behavior

React Testing Library (RTL) is built on a simple premise: test your components the way users interact with them. This means:

  • Query elements by their accessible names, labels, and text content
  • Interact with elements like a user would (clicking, typing, etc.)
  • Assert on what users can see and experience
  • Avoid testing implementation details

The more your tests resemble the way your software is used, the more confidence they can give you. - React Testing Library guiding principle

Benefits of This Approach

  • Refactoring-friendly: Tests don’t break when implementation changes
  • Accessibility-first: Encourages writing accessible components
  • Maintainable: Tests are easier to read and understand
  • Confidence: Tests verify actual user experience

Setup and Configuration

Installation

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

For TypeScript projects:

npm install --save-dev @types/react @types/jest

Basic Configuration

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    css: true,
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

src/test/setup.ts

import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
 
// Cleanup after each test
afterEach(() => {
  cleanup()
})
 
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})
⚠️

Always import @testing-library/jest-dom in your setup file to get helpful matchers like toBeInTheDocument() and toHaveTextContent().

Querying Elements

RTL provides three types of queries, each with different variants based on how many elements you expect.

Query Types

Query TypeReturnsThrows on No MatchWaitsUse Case
getBy...ElementYesNoElements that should be present
queryBy...Element or nullNoNoElements that may not exist
findBy...Promise<Element>YesYesElements that appear async

Query Variants

import { render, screen } from '@testing-library/react'
 
function UserProfile({ name, bio }: { name: string; bio?: string }) {
  return (
    <div>
      <h1>{name}</h1>
      {bio && <p>{bio}</p>}
    </div>
  )
}
 
describe('Query Examples', () => {
  it('demonstrates different query types', async () => {
    render(<UserProfile name="Jane Doe" bio="Software Engineer" />)
 
    // getBy - throws if not found (use for elements that MUST exist)
    const heading = screen.getByRole('heading', { name: /jane doe/i })
    expect(heading).toBeInTheDocument()
 
    // queryBy - returns null if not found (use for elements that may not exist)
    const button = screen.queryByRole('button')
    expect(button).not.toBeInTheDocument()
 
    // findBy - async, waits for element (use for elements that appear after render)
    const bio = await screen.findByText(/software engineer/i)
    expect(bio).toBeInTheDocument()
  })
})

Query Priority

Use queries in this order of preference:

  1. Accessible to Everyone

    • getByRole - Best choice for most elements
    • getByLabelText - Form fields with labels
    • getByPlaceholderText - Form inputs with placeholders
    • getByText - Non-interactive elements (divs, spans, paragraphs)
  2. Semantic Queries

    • getByAltText - Images with alt text
    • getByTitle - Elements with title attribute
  3. Test IDs (Last Resort)

    • getByTestId - Use only when other queries don’t work

Prefer getByRole - It’s the most robust query that also encourages accessible markup. Use the testing-library.com/docs/queries/byrole documentation to find the right role.

Common Query Examples

// Buttons
screen.getByRole('button', { name: /submit/i })
 
// Links
screen.getByRole('link', { name: /learn more/i })
 
// Form inputs
screen.getByRole('textbox', { name: /email/i })
screen.getByLabelText(/password/i)
 
// Checkboxes and radios
screen.getByRole('checkbox', { name: /agree to terms/i })
screen.getByRole('radio', { name: /option 1/i })
 
// Headings
screen.getByRole('heading', { level: 1, name: /welcome/i })
 
// Images
screen.getByRole('img', { name: /profile photo/i })
screen.getByAltText(/profile photo/i)
 
// Lists and items
screen.getByRole('list')
screen.getAllByRole('listitem')
 
// Text content
screen.getByText(/welcome back/i)
 
// Test IDs (last resort)
screen.getByTestId('custom-element')

User Interactions

Using fireEvent (Simple)

fireEvent dispatches DOM events directly. It’s synchronous and simpler but doesn’t fully simulate user behavior.

import { render, screen, fireEvent } from '@testing-library/react'
 
function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}
 
it('increments counter with fireEvent', () => {
  render(<Counter />)
  
  const button = screen.getByRole('button', { name: /increment/i })
  fireEvent.click(button)
  
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})

userEvent simulates full user interactions, including focus, hover, and keyboard events. It’s asynchronous and more realistic.

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
 
function LoginForm({ onSubmit }: { onSubmit: (data: any) => void }) {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    onSubmit(Object.fromEntries(formData))
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" />
      
      <button type="submit">Sign In</button>
    </form>
  )
}
 
it('submits form with user input', async () => {
  const user = userEvent.setup()
  const handleSubmit = vi.fn()
  
  render(<LoginForm onSubmit={handleSubmit} />)
  
  // Type into inputs
  await user.type(screen.getByLabelText(/email/i), 'user@example.com')
  await user.type(screen.getByLabelText(/password/i), 'password123')
  
  // Click submit
  await user.click(screen.getByRole('button', { name: /sign in/i }))
  
  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'user@example.com',
    password: 'password123',
  })
})
⚠️

Always use userEvent.setup() at the beginning of each test for the best simulation of user interactions. Don’t share the user instance between tests.

Common User Interactions

const user = userEvent.setup()
 
// Typing
await user.type(input, 'Hello World')
await user.clear(input)
 
// Clicking
await user.click(button)
await user.dblClick(button)
await user.tripleClick(button)
 
// Keyboard
await user.keyboard('{Enter}')
await user.keyboard('{Shift>}A{/Shift}') // Shift+A
await user.tab() // Tab navigation
 
// Selection
await user.selectOptions(select, 'option1')
await user.deselectOptions(select, 'option1')
 
// Checkboxes and radios
await user.click(checkbox)
 
// Hover
await user.hover(element)
await user.unhover(element)
 
// Upload files
const file = new File(['content'], 'test.png', { type: 'image/png' })
await user.upload(input, file)

Testing Hooks with renderHook

For testing custom hooks in isolation, use renderHook from React Testing Library.

import { renderHook, waitFor } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
 
// Custom hook
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)
  
  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(initialValue)
  
  return { count, increment, decrement, reset }
}
 
describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })
 
  it('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })
 
  it('increments counter', () => {
    const { result } = renderHook(() => useCounter())
    
    act(() => {
      result.current.increment()
    })
    
    expect(result.current.count).toBe(1)
  })
 
  it('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(5))
    
    act(() => {
      result.current.increment()
      result.current.increment()
    })
    
    expect(result.current.count).toBe(7)
    
    act(() => {
      result.current.reset()
    })
    
    expect(result.current.count).toBe(5)
  })
})

Testing Hooks with Dependencies

function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)
 
  useEffect(() => {
    let cancelled = false
    
    fetch(url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setData(data)
          setLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err)
          setLoading(false)
        }
      })
    
    return () => {
      cancelled = true
    }
  }, [url])
 
  return { data, loading, error }
}
 
describe('useFetch', () => {
  beforeEach(() => {
    global.fetch = vi.fn()
  })
 
  it('fetches data successfully', async () => {
    const mockData = { id: 1, name: 'Test' }
    ;(global.fetch as any).mockResolvedValueOnce({
      json: async () => mockData,
    })
 
    const { result } = renderHook(() => useFetch('/api/data'))
 
    expect(result.current.loading).toBe(true)
    expect(result.current.data).toBe(null)
 
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })
 
    expect(result.current.data).toEqual(mockData)
    expect(result.current.error).toBe(null)
  })
 
  it('handles errors', async () => {
    const mockError = new Error('Failed to fetch')
    ;(global.fetch as any).mockRejectedValueOnce(mockError)
 
    const { result } = renderHook(() => useFetch('/api/data'))
 
    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })
 
    expect(result.current.error).toEqual(mockError)
    expect(result.current.data).toBe(null)
  })
 
  it('refetches when URL changes', async () => {
    const mockData1 = { id: 1 }
    const mockData2 = { id: 2 }
    
    ;(global.fetch as any)
      .mockResolvedValueOnce({ json: async () => mockData1 })
      .mockResolvedValueOnce({ json: async () => mockData2 })
 
    const { result, rerender } = renderHook(
      ({ url }) => useFetch(url),
      { initialProps: { url: '/api/data/1' } }
    )
 
    await waitFor(() => {
      expect(result.current.data).toEqual(mockData1)
    })
 
    rerender({ url: '/api/data/2' })
 
    await waitFor(() => {
      expect(result.current.data).toEqual(mockData2)
    })
  })
})

Testing Context

When testing components that use React Context, wrap them with the context provider.

import { createContext, useContext, useState, ReactNode } from 'react'
 
// Context setup
interface AuthContextType {
  user: { name: string; email: string } | null
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}
 
const AuthContext = createContext<AuthContextType | undefined>(undefined)
 
function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<{ name: string; email: string } | null>(null)
 
  const login = async (email: string, password: string) => {
    // Simulate API call
    setUser({ name: 'John Doe', email })
  }
 
  const logout = () => setUser(null)
 
  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  )
}
 
function useAuth() {
  const context = useContext(AuthContext)
  if (!context) throw new Error('useAuth must be used within AuthProvider')
  return context
}
 
// Component using context
function UserProfile() {
  const { user, logout } = useAuth()
 
  if (!user) {
    return <div>Please log in</div>
  }
 
  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>{user.email}</p>
      <button onClick={logout}>Logout</button>
    </div>
  )
}
 
// Tests
describe('UserProfile with Context', () => {
  it('shows login message when no user', () => {
    render(
      <AuthProvider>
        <UserProfile />
      </AuthProvider>
    )
 
    expect(screen.getByText(/please log in/i)).toBeInTheDocument()
  })
 
  it('shows user info when logged in', async () => {
    const user = userEvent.setup()
    
    function TestComponent() {
      const { login } = useAuth()
      
      return (
        <>
          <button onClick={() => login('test@example.com', 'password')}>
            Login
          </button>
          <UserProfile />
        </>
      )
    }
 
    render(
      <AuthProvider>
        <TestComponent />
      </AuthProvider>
    )
 
    await user.click(screen.getByRole('button', { name: /login/i }))
 
    expect(await screen.findByText(/welcome, john doe/i)).toBeInTheDocument()
    expect(screen.getByText(/test@example.com/i)).toBeInTheDocument()
  })
 
  it('logs out user', async () => {
    const user = userEvent.setup()
    
    function TestComponent() {
      const { login } = useAuth()
      
      useEffect(() => {
        login('test@example.com', 'password')
      }, [])
      
      return <UserProfile />
    }
 
    render(
      <AuthProvider>
        <TestComponent />
      </AuthProvider>
    )
 
    expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
 
    await user.click(screen.getByRole('button', { name: /logout/i }))
 
    expect(screen.getByText(/please log in/i)).toBeInTheDocument()
  })
})

Creating Custom Render with Providers

import { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
 
interface AllProvidersProps {
  children: ReactNode
}
 
function AllProviders({ children }: AllProvidersProps) {
  return (
    <AuthProvider>
      <ThemeProvider>
        {children}
      </ThemeProvider>
    </AuthProvider>
  )
}
 
function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  return render(ui, { wrapper: AllProviders, ...options })
}
 
// Re-export everything
export * from '@testing-library/react'
export { customRender as render }
 
// Usage in tests
import { render, screen } from '@/test/utils'
 
it('uses custom render', () => {
  render(<UserProfile />)
  // All providers are automatically wrapped
})

Create a custom render function in a test utilities file to automatically wrap components with commonly used providers.

Async Testing

Waiting for Elements

import { render, screen, waitFor } from '@testing-library/react'
 
function AsyncComponent() {
  const [data, setData] = useState<string | null>(null)
 
  useEffect(() => {
    setTimeout(() => {
      setData('Loaded data')
    }, 1000)
  }, [])
 
  if (!data) return <div>Loading...</div>
 
  return <div>{data}</div>
}
 
describe('Async Testing', () => {
  it('waits for element with findBy', async () => {
    render(<AsyncComponent />)
    
    // findBy automatically waits (default timeout: 1000ms)
    const element = await screen.findByText(/loaded data/i)
    expect(element).toBeInTheDocument()
  })
 
  it('waits for element with waitFor', async () => {
    render(<AsyncComponent />)
    
    await waitFor(() => {
      expect(screen.getByText(/loaded data/i)).toBeInTheDocument()
    })
  })
 
  it('waits for element to disappear', async () => {
    render(<AsyncComponent />)
    
    expect(screen.getByText(/loading/i)).toBeInTheDocument()
    
    await waitFor(() => {
      expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
    })
  })
 
  it('uses custom timeout', async () => {
    render(<AsyncComponent />)
    
    const element = await screen.findByText(
      /loaded data/i,
      {},
      { timeout: 3000 }
    )
    expect(element).toBeInTheDocument()
  })
})

Testing API Calls

function UserList() {
  const [users, setUsers] = useState<Array<{ id: number; name: string }>>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
 
  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data)
        setLoading(false)
      })
      .catch(err => {
        setError(err.message)
        setLoading(false)
      })
  }, [])
 
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error}</div>
 
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}
 
describe('UserList', () => {
  beforeEach(() => {
    global.fetch = vi.fn()
  })
 
  afterEach(() => {
    vi.restoreAllMocks()
  })
 
  it('displays loading state', () => {
    ;(global.fetch as any).mockImplementationOnce(() => 
      new Promise(() => {}) // Never resolves
    )
 
    render(<UserList />)
    expect(screen.getByText(/loading/i)).toBeInTheDocument()
  })
 
  it('displays users after loading', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ]
 
    ;(global.fetch as any).mockResolvedValueOnce({
      json: async () => mockUsers,
    })
 
    render(<UserList />)
 
    const items = await screen.findAllByRole('listitem')
    expect(items).toHaveLength(2)
    expect(screen.getByText(/alice/i)).toBeInTheDocument()
    expect(screen.getByText(/bob/i)).toBeInTheDocument()
  })
 
  it('displays error message on failure', async () => {
    ;(global.fetch as any).mockRejectedValueOnce(
      new Error('Network error')
    )
 
    render(<UserList />)
 
    expect(await screen.findByText(/error: network error/i)).toBeInTheDocument()
  })
})

Component Testing Patterns

Testing Props

interface ButtonProps {
  label: string
  variant?: 'primary' | 'secondary'
  disabled?: boolean
  onClick?: () => void
}
 
function Button({ label, variant = 'primary', disabled, onClick }: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant}`}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
    </button>
  )
}
 
describe('Button', () => {
  it('renders with label', () => {
    render(<Button label="Click me" />)
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
  })
 
  it('applies variant class', () => {
    render(<Button label="Click" variant="secondary" />)
    const button = screen.getByRole('button')
    expect(button).toHaveClass('btn-secondary')
  })
 
  it('can be disabled', () => {
    render(<Button label="Click" disabled />)
    expect(screen.getByRole('button')).toBeDisabled()
  })
 
  it('calls onClick handler', async () => {
    const user = userEvent.setup()
    const handleClick = vi.fn()
    
    render(<Button label="Click" onClick={handleClick} />)
    
    await user.click(screen.getByRole('button'))
    expect(handleClick).toHaveBeenCalledTimes(1)
  })
})

Testing State

function Toggle() {
  const [isOn, setIsOn] = useState(false)
 
  return (
    <div>
      <p>Status: {isOn ? 'ON' : 'OFF'}</p>
      <button onClick={() => setIsOn(!isOn)}>
        Toggle
      </button>
    </div>
  )
}
 
describe('Toggle', () => {
  it('starts in OFF state', () => {
    render(<Toggle />)
    expect(screen.getByText(/status: off/i)).toBeInTheDocument()
  })
 
  it('toggles to ON when clicked', async () => {
    const user = userEvent.setup()
    render(<Toggle />)
    
    await user.click(screen.getByRole('button', { name: /toggle/i }))
    
    expect(screen.getByText(/status: on/i)).toBeInTheDocument()
  })
 
  it('toggles back to OFF', async () => {
    const user = userEvent.setup()
    render(<Toggle />)
    
    const button = screen.getByRole('button', { name: /toggle/i })
    
    await user.click(button)
    expect(screen.getByText(/status: on/i)).toBeInTheDocument()
    
    await user.click(button)
    expect(screen.getByText(/status: off/i)).toBeInTheDocument()
  })
})

Testing Events

function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('')
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    onSearch(query)
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  )
}
 
describe('SearchInput', () => {
  it('calls onSearch with query', async () => {
    const user = userEvent.setup()
    const handleSearch = vi.fn()
    
    render(<SearchInput onSearch={handleSearch} />)
    
    await user.type(screen.getByPlaceholderText(/search/i), 'React Testing')
    await user.click(screen.getByRole('button', { name: /search/i }))
    
    expect(handleSearch).toHaveBeenCalledWith('React Testing')
  })
 
  it('submits on Enter key', async () => {
    const user = userEvent.setup()
    const handleSearch = vi.fn()
    
    render(<SearchInput onSearch={handleSearch} />)
    
    const input = screen.getByPlaceholderText(/search/i)
    await user.type(input, 'React Testing{Enter}')
    
    expect(handleSearch).toHaveBeenCalledWith('React Testing')
  })
})

Testing Conditional Rendering

interface MessageProps {
  type: 'success' | 'error' | 'info'
  message: string
  onClose?: () => void
}
 
function Message({ type, message, onClose }: MessageProps) {
  return (
    <div className={`alert alert-${type}`} role="alert">
      <p>{message}</p>
      {onClose && (
        <button onClick={onClose} aria-label="Close">
          ×
        </button>
      )}
    </div>
  )
}
 
describe('Message', () => {
  it('renders success message', () => {
    render(<Message type="success" message="Operation successful" />)
    
    const alert = screen.getByRole('alert')
    expect(alert).toHaveClass('alert-success')
    expect(screen.getByText(/operation successful/i)).toBeInTheDocument()
  })
 
  it('renders close button when onClose provided', () => {
    const handleClose = vi.fn()
    render(<Message type="info" message="Info" onClose={handleClose} />)
    
    expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument()
  })
 
  it('does not render close button without onClose', () => {
    render(<Message type="info" message="Info" />)
    
    expect(screen.queryByRole('button', { name: /close/i })).not.toBeInTheDocument()
  })
 
  it('calls onClose when close button clicked', async () => {
    const user = userEvent.setup()
    const handleClose = vi.fn()
    
    render(<Message type="info" message="Info" onClose={handleClose} />)
    
    await user.click(screen.getByRole('button', { name: /close/i }))
    expect(handleClose).toHaveBeenCalledTimes(1)
  })
})

Testing Forms

interface FormData {
  username: string
  email: string
  age: number
  terms: boolean
}
 
interface RegistrationFormProps {
  onSubmit: (data: FormData) => void
}
 
function RegistrationForm({ onSubmit }: RegistrationFormProps) {
  const [errors, setErrors] = useState<Record<string, string>>({})
 
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    
    const data = {
      username: formData.get('username') as string,
      email: formData.get('email') as string,
      age: Number(formData.get('age')),
      terms: formData.get('terms') === 'on',
    }
 
    // Validation
    const newErrors: Record<string, string> = {}
    if (!data.username) newErrors.username = 'Username is required'
    if (!data.email) newErrors.email = 'Email is required'
    if (!data.terms) newErrors.terms = 'You must accept the terms'
 
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors)
      return
    }
 
    setErrors({})
    onSubmit(data)
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">Username</label>
        <input id="username" name="username" type="text" />
        {errors.username && <span role="alert">{errors.username}</span>}
      </div>
 
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {errors.email && <span role="alert">{errors.email}</span>}
      </div>
 
      <div>
        <label htmlFor="age">Age</label>
        <input id="age" name="age" type="number" />
      </div>
 
      <div>
        <input id="terms" name="terms" type="checkbox" />
        <label htmlFor="terms">I accept the terms and conditions</label>
        {errors.terms && <span role="alert">{errors.terms}</span>}
      </div>
 
      <button type="submit">Register</button>
    </form>
  )
}
 
describe('RegistrationForm', () => {
  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
    
    render(<RegistrationForm onSubmit={handleSubmit} />)
    
    await user.type(screen.getByLabelText(/username/i), 'johndoe')
    await user.type(screen.getByLabelText(/email/i), 'john@example.com')
    await user.type(screen.getByLabelText(/age/i), '25')
    await user.click(screen.getByLabelText(/accept the terms/i))
    
    await user.click(screen.getByRole('button', { name: /register/i }))
    
    expect(handleSubmit).toHaveBeenCalledWith({
      username: 'johndoe',
      email: 'john@example.com',
      age: 25,
      terms: true,
    })
  })
 
  it('shows validation errors for empty fields', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
    
    render(<RegistrationForm onSubmit={handleSubmit} />)
    
    await user.click(screen.getByRole('button', { name: /register/i }))
    
    expect(screen.getByText(/username is required/i)).toBeInTheDocument()
    expect(screen.getByText(/email is required/i)).toBeInTheDocument()
    expect(screen.getByText(/you must accept the terms/i)).toBeInTheDocument()
    expect(handleSubmit).not.toHaveBeenCalled()
  })
 
  it('clears errors when form is filled correctly', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()
    
    render(<RegistrationForm onSubmit={handleSubmit} />)
    
    // Submit empty form to trigger errors
    await user.click(screen.getByRole('button', { name: /register/i }))
    expect(screen.getByText(/username is required/i)).toBeInTheDocument()
    
    // Fill form correctly
    await user.type(screen.getByLabelText(/username/i), 'johndoe')
    await user.type(screen.getByLabelText(/email/i), 'john@example.com')
    await user.click(screen.getByLabelText(/accept the terms/i))
    await user.click(screen.getByRole('button', { name: /register/i }))
    
    // Errors should be gone
    expect(screen.queryByText(/username is required/i)).not.toBeInTheDocument()
    expect(handleSubmit).toHaveBeenCalled()
  })
})

Best Practices

1. Test Behavior, Not Implementation

Bad - Testing implementation details:

it('updates state when button clicked', () => {
  const wrapper = shallow(<Counter />)
  wrapper.find('button').simulate('click')
  expect(wrapper.state('count')).toBe(1) // Testing internal state
})

Good - Testing behavior:

it('increments counter when button clicked', async () => {
  const user = userEvent.setup()
  render(<Counter />)
  
  await user.click(screen.getByRole('button', { name: /increment/i }))
  
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})

2. Use Accessible Queries

Bad:

const button = container.querySelector('.submit-button')
const input = container.querySelector('#email-input')

Good:

const button = screen.getByRole('button', { name: /submit/i })
const input = screen.getByLabelText(/email/i)

3. Don’t Test Third-Party Libraries

Bad:

it('renders with correct className from classnames library', () => {
  // Testing classnames library, not your code
})

Good:

it('applies active class when active prop is true', () => {
  render(<Tab active />)
  expect(screen.getByRole('tab')).toHaveClass('active')
})

4. Keep Tests Simple and Focused

Bad - Testing multiple things:

it('does everything', async () => {
  // Tests rendering, clicking, form submission, API calls, etc.
  // Too many responsibilities
})

Good - One concept per test:

it('renders login form', () => {
  render(<LoginForm />)
  expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
})
 
it('submits form with credentials', async () => {
  // Test only submission
})
 
it('shows error on invalid credentials', async () => {
  // Test only error display
})

5. Use Data-Testid as Last Resort

Only use data-testid when semantic queries don’t work:

// Use only when element has no accessible role, label, or text
<div data-testid="custom-chart">
  <svg>{/* Complex chart */}</svg>
</div>
 
// In test
screen.getByTestId('custom-chart')

6. Avoid Snapshot Tests for UI

Bad - Brittle and hard to maintain:

it('matches snapshot', () => {
  const { container } = render(<MyComponent />)
  expect(container).toMatchSnapshot()
})

Good - Test specific behaviors:

it('renders heading with correct text', () => {
  render(<MyComponent title="Welcome" />)
  expect(screen.getByRole('heading', { name: /welcome/i })).toBeInTheDocument()
})

7. Clean Up After Tests

import { cleanup } from '@testing-library/react'
 
afterEach(() => {
  cleanup() // Automatically done if you import from @testing-library/react
  vi.clearAllMocks() // Clear mocks
  vi.restoreAllMocks() // Restore original implementations
})

8. Use Testing Library Queries Correctly

// ❌ Don't use assertion with queryBy
expect(screen.queryByText('Hello')).toBeInTheDocument()
 
// ✅ Use getBy for existence assertions
expect(screen.getByText('Hello')).toBeInTheDocument()
 
// ✅ Use queryBy for non-existence assertions
expect(screen.queryByText('Goodbye')).not.toBeInTheDocument()
 
// ✅ Use findBy for async elements
expect(await screen.findByText('Loaded')).toBeInTheDocument()

9. Prefer User Event over Fire Event

// ❌ Avoid fireEvent (too low-level)
fireEvent.change(input, { target: { value: 'test' } })
fireEvent.click(button)
 
// ✅ Use userEvent (simulates real user interaction)
const user = userEvent.setup()
await user.type(input, 'test')
await user.click(button)

10. Structure Tests with AAA Pattern

it('increments counter', async () => {
  // Arrange - Set up the test
  const user = userEvent.setup()
  render(<Counter initialValue={0} />)
  
  // Act - Perform the action
  await user.click(screen.getByRole('button', { name: /increment/i }))
  
  // Assert - Verify the result
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})

For more testing best practices, see Best Practices and Testing Fundamentals.