dify/.claude/skills/frontend-testing/references/common-patterns.md

12 KiB

Common Testing Patterns

Query Priority

Use queries in this order (most to least preferred):

// 1. getByRole - Most recommended (accessibility)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('heading', { level: 1 })

// 2. getByLabelText - Form fields
screen.getByLabelText('Email address')
screen.getByLabelText(/password/i)

// 3. getByPlaceholderText - When no label
screen.getByPlaceholderText('Search...')

// 4. getByText - Non-interactive elements
screen.getByText('Welcome to Dify')
screen.getByText(/loading/i)

// 5. getByDisplayValue - Current input value
screen.getByDisplayValue('current value')

// 6. getByAltText - Images
screen.getByAltText('Company logo')

// 7. getByTitle - Tooltip elements
screen.getByTitle('Close')

// 8. getByTestId - Last resort only!
screen.getByTestId('custom-element')

Event Handling Patterns

Click Events

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

// With userEvent (preferred for realistic interaction)
const user = userEvent.setup()
await user.click(screen.getByRole('button'))

// Double click
await user.dblClick(screen.getByRole('button'))

// Right click
await user.pointer({ keys: '[MouseRight]', target: screen.getByRole('button') })

Form Input

const user = userEvent.setup()

// Type in input
await user.type(screen.getByRole('textbox'), 'Hello World')

// Clear and type
await user.clear(screen.getByRole('textbox'))
await user.type(screen.getByRole('textbox'), 'New value')

// Select option
await user.selectOptions(screen.getByRole('combobox'), 'option-value')

// Check checkbox
await user.click(screen.getByRole('checkbox'))

// Upload file
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await user.upload(screen.getByLabelText(/upload/i), file)

Keyboard Events

const user = userEvent.setup()

// Press Enter
await user.keyboard('{Enter}')

// Press Escape
await user.keyboard('{Escape}')

// Keyboard shortcut
await user.keyboard('{Control>}a{/Control}') // Ctrl+A

// Tab navigation
await user.tab()

// Arrow keys
await user.keyboard('{ArrowDown}')
await user.keyboard('{ArrowUp}')

Component State Testing

Testing State Transitions

describe('Counter', () => {
  it('should increment count', async () => {
    const user = userEvent.setup()
    render(<Counter initialCount={0} />)
    
    // Initial state
    expect(screen.getByText('Count: 0')).toBeInTheDocument()
    
    // Trigger transition
    await user.click(screen.getByRole('button', { name: /increment/i }))
    
    // New state
    expect(screen.getByText('Count: 1')).toBeInTheDocument()
  })
})

Testing Controlled Components

describe('ControlledInput', () => {
  it('should call onChange with new value', async () => {
    const user = userEvent.setup()
    const handleChange = jest.fn()
    
    render(<ControlledInput value="" onChange={handleChange} />)
    
    await user.type(screen.getByRole('textbox'), 'a')
    
    expect(handleChange).toHaveBeenCalledWith('a')
  })

  it('should display controlled value', () => {
    render(<ControlledInput value="controlled" onChange={jest.fn()} />)
    
    expect(screen.getByRole('textbox')).toHaveValue('controlled')
  })
})

Conditional Rendering Testing

describe('ConditionalComponent', () => {
  it('should show loading state', () => {
    render(<DataDisplay isLoading={true} data={null} />)
    
    expect(screen.getByText(/loading/i)).toBeInTheDocument()
    expect(screen.queryByTestId('data-content')).not.toBeInTheDocument()
  })

  it('should show error state', () => {
    render(<DataDisplay isLoading={false} data={null} error="Failed to load" />)
    
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
  })

  it('should show data when loaded', () => {
    render(<DataDisplay isLoading={false} data={{ name: 'Test' }} />)
    
    expect(screen.getByText('Test')).toBeInTheDocument()
  })

  it('should show empty state when no data', () => {
    render(<DataDisplay isLoading={false} data={[]} />)
    
    expect(screen.getByText(/no data/i)).toBeInTheDocument()
  })
})

List Rendering Testing

describe('ItemList', () => {
  const items = [
    { id: '1', name: 'Item 1' },
    { id: '2', name: 'Item 2' },
    { id: '3', name: 'Item 3' },
  ]

  it('should render all items', () => {
    render(<ItemList items={items} />)
    
    expect(screen.getAllByRole('listitem')).toHaveLength(3)
    items.forEach(item => {
      expect(screen.getByText(item.name)).toBeInTheDocument()
    })
  })

  it('should handle item selection', async () => {
    const user = userEvent.setup()
    const onSelect = jest.fn()
    
    render(<ItemList items={items} onSelect={onSelect} />)
    
    await user.click(screen.getByText('Item 2'))
    
    expect(onSelect).toHaveBeenCalledWith(items[1])
  })

  it('should handle empty list', () => {
    render(<ItemList items={[]} />)
    
    expect(screen.getByText(/no items/i)).toBeInTheDocument()
  })
})

Modal/Dialog Testing

describe('Modal', () => {
  it('should not render when closed', () => {
    render(<Modal isOpen={false} onClose={jest.fn()} />)
    
    expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
  })

  it('should render when open', () => {
    render(<Modal isOpen={true} onClose={jest.fn()} />)
    
    expect(screen.getByRole('dialog')).toBeInTheDocument()
  })

  it('should call onClose when clicking overlay', async () => {
    const user = userEvent.setup()
    const handleClose = jest.fn()
    
    render(<Modal isOpen={true} onClose={handleClose} />)
    
    await user.click(screen.getByTestId('modal-overlay'))
    
    expect(handleClose).toHaveBeenCalled()
  })

  it('should call onClose when pressing Escape', async () => {
    const user = userEvent.setup()
    const handleClose = jest.fn()
    
    render(<Modal isOpen={true} onClose={handleClose} />)
    
    await user.keyboard('{Escape}')
    
    expect(handleClose).toHaveBeenCalled()
  })

  it('should trap focus inside modal', async () => {
    const user = userEvent.setup()
    
    render(
      <Modal isOpen={true} onClose={jest.fn()}>
        <button>First</button>
        <button>Second</button>
      </Modal>
    )
    
    // Focus should cycle within modal
    await user.tab()
    expect(screen.getByText('First')).toHaveFocus()
    
    await user.tab()
    expect(screen.getByText('Second')).toHaveFocus()
    
    await user.tab()
    expect(screen.getByText('First')).toHaveFocus() // Cycles back
  })
})

Form Testing

describe('LoginForm', () => {
  it('should submit valid form', async () => {
    const user = userEvent.setup()
    const onSubmit = jest.fn()
    
    render(<LoginForm onSubmit={onSubmit} />)
    
    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(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    })
  })

  it('should show validation errors', async () => {
    const user = userEvent.setup()
    
    render(<LoginForm onSubmit={jest.fn()} />)
    
    // Submit empty form
    await user.click(screen.getByRole('button', { name: /sign in/i }))
    
    expect(screen.getByText(/email is required/i)).toBeInTheDocument()
    expect(screen.getByText(/password is required/i)).toBeInTheDocument()
  })

  it('should validate email format', async () => {
    const user = userEvent.setup()
    
    render(<LoginForm onSubmit={jest.fn()} />)
    
    await user.type(screen.getByLabelText(/email/i), 'invalid-email')
    await user.click(screen.getByRole('button', { name: /sign in/i }))
    
    expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
  })

  it('should disable submit button while submitting', async () => {
    const user = userEvent.setup()
    const onSubmit = jest.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
    
    render(<LoginForm onSubmit={onSubmit} />)
    
    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(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
    
    await waitFor(() => {
      expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled()
    })
  })
})

Data-Driven Tests with test.each

describe('StatusBadge', () => {
  test.each([
    ['success', 'bg-green-500'],
    ['warning', 'bg-yellow-500'],
    ['error', 'bg-red-500'],
    ['info', 'bg-blue-500'],
  ])('should apply correct class for %s status', (status, expectedClass) => {
    render(<StatusBadge status={status} />)
    
    expect(screen.getByTestId('status-badge')).toHaveClass(expectedClass)
  })

  test.each([
    { input: null, expected: 'Unknown' },
    { input: undefined, expected: 'Unknown' },
    { input: '', expected: 'Unknown' },
    { input: 'invalid', expected: 'Unknown' },
  ])('should show "Unknown" for invalid input: $input', ({ input, expected }) => {
    render(<StatusBadge status={input} />)
    
    expect(screen.getByText(expected)).toBeInTheDocument()
  })
})

Debugging Tips

// Print entire DOM
screen.debug()

// Print specific element
screen.debug(screen.getByRole('button'))

// Log testing playground URL
screen.logTestingPlaygroundURL()

// Pretty print DOM
import { prettyDOM } from '@testing-library/react'
console.log(prettyDOM(screen.getByRole('dialog')))

// Check available roles
import { getRoles } from '@testing-library/react'
console.log(getRoles(container))

Common Mistakes to Avoid

Don't Use Implementation Details

// Bad - testing implementation
expect(component.state.isOpen).toBe(true)
expect(wrapper.find('.internal-class').length).toBe(1)

// Good - testing behavior
expect(screen.getByRole('dialog')).toBeInTheDocument()

Don't Forget Cleanup

// Bad - may leak state between tests
it('test 1', () => {
  render(<Component />)
})

// Good - cleanup is automatic with RTL, but reset mocks
beforeEach(() => {
  jest.clearAllMocks()
})

Don't Use Exact String Matching (Prefer Black-Box Assertions)

// ❌ Bad - hardcoded strings are brittle
expect(screen.getByText('Submit Form')).toBeInTheDocument()
expect(screen.getByText('Loading...')).toBeInTheDocument()

// ✅ Good - role-based queries (most semantic)
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()

// ✅ Good - pattern matching (flexible)
expect(screen.getByText(/submit/i)).toBeInTheDocument()
expect(screen.getByText(/loading/i)).toBeInTheDocument()

// ✅ Good - test behavior, not exact UI text
expect(screen.getByRole('button')).toBeDisabled()
expect(screen.getByRole('alert')).toBeInTheDocument()

Why prefer black-box assertions?

  • Text content may change (i18n, copy updates)
  • Role-based queries test accessibility
  • Pattern matching is resilient to minor changes
  • Tests focus on behavior, not implementation details

Don't Assert on Absence Without Query

// Bad - throws if not found
expect(screen.getByText('Error')).not.toBeInTheDocument() // Error!

// Good - use queryBy for absence assertions
expect(screen.queryByText('Error')).not.toBeInTheDocument()