dify/.claude/skills/frontend-testing/references/async-testing.md

8.3 KiB

Async Testing Guide

Core Async Patterns

1. waitFor - Wait for Condition

import { render, screen, waitFor } from '@testing-library/react'

it('should load and display data', async () => {
  render(<DataComponent />)
  
  // Wait for element to appear
  await waitFor(() => {
    expect(screen.getByText('Loaded Data')).toBeInTheDocument()
  })
})

it('should hide loading spinner after load', async () => {
  render(<DataComponent />)
  
  // Wait for element to disappear
  await waitFor(() => {
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
  })
})

2. findBy* - Async Queries

it('should show user name after fetch', async () => {
  render(<UserProfile />)
  
  // findBy returns a promise, auto-waits up to 1000ms
  const userName = await screen.findByText('John Doe')
  expect(userName).toBeInTheDocument()
  
  // findByRole with options
  const button = await screen.findByRole('button', { name: /submit/i })
  expect(button).toBeEnabled()
})

3. userEvent for Async Interactions

import userEvent from '@testing-library/user-event'

it('should submit form', async () => {
  const user = userEvent.setup()
  const onSubmit = jest.fn()
  
  render(<Form onSubmit={onSubmit} />)
  
  // userEvent methods are async
  await user.type(screen.getByLabelText('Email'), 'test@example.com')
  await user.click(screen.getByRole('button', { name: /submit/i }))
  
  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' })
  })
})

Fake Timers

When to Use Fake Timers

  • Testing components with setTimeout/setInterval
  • Testing debounce/throttle behavior
  • Testing animations or delayed transitions
  • Testing polling or retry logic

Basic Fake Timer Setup

describe('Debounced Search', () => {
  beforeEach(() => {
    jest.useFakeTimers()
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('should debounce search input', async () => {
    const onSearch = jest.fn()
    render(<SearchInput onSearch={onSearch} debounceMs={300} />)
    
    // Type in the input
    fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } })
    
    // Search not called immediately
    expect(onSearch).not.toHaveBeenCalled()
    
    // Advance timers
    jest.advanceTimersByTime(300)
    
    // Now search is called
    expect(onSearch).toHaveBeenCalledWith('query')
  })
})

Fake Timers with Async Code

it('should retry on failure', async () => {
  jest.useFakeTimers()
  const fetchData = jest.fn()
    .mockRejectedValueOnce(new Error('Network error'))
    .mockResolvedValueOnce({ data: 'success' })
  
  render(<RetryComponent fetchData={fetchData} retryDelayMs={1000} />)
  
  // First call fails
  await waitFor(() => {
    expect(fetchData).toHaveBeenCalledTimes(1)
  })
  
  // Advance timer for retry
  jest.advanceTimersByTime(1000)
  
  // Second call succeeds
  await waitFor(() => {
    expect(fetchData).toHaveBeenCalledTimes(2)
    expect(screen.getByText('success')).toBeInTheDocument()
  })
  
  jest.useRealTimers()
})

Common Fake Timer Utilities

// Run all pending timers
jest.runAllTimers()

// Run only pending timers (not new ones created during execution)
jest.runOnlyPendingTimers()

// Advance by specific time
jest.advanceTimersByTime(1000)

// Get current fake time
jest.now()

// Clear all timers
jest.clearAllTimers()

API Testing Patterns

Loading → Success → Error States

describe('DataFetcher', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('should show loading state', () => {
    mockedApi.fetchData.mockImplementation(() => new Promise(() => {})) // Never resolves
    
    render(<DataFetcher />)
    
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
  })

  it('should show data on success', async () => {
    mockedApi.fetchData.mockResolvedValue({ items: ['Item 1', 'Item 2'] })
    
    render(<DataFetcher />)
    
    // Use findBy* for multiple async elements (better error messages than waitFor with multiple assertions)
    const item1 = await screen.findByText('Item 1')
    const item2 = await screen.findByText('Item 2')
    expect(item1).toBeInTheDocument()
    expect(item2).toBeInTheDocument()
    
    expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument()
  })

  it('should show error on failure', async () => {
    mockedApi.fetchData.mockRejectedValue(new Error('Failed to fetch'))
    
    render(<DataFetcher />)
    
    await waitFor(() => {
      expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument()
    })
  })

  it('should retry on error', async () => {
    mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
    
    render(<DataFetcher />)
    
    await waitFor(() => {
      expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
    })
    
    mockedApi.fetchData.mockResolvedValue({ items: ['Item 1'] })
    fireEvent.click(screen.getByRole('button', { name: /retry/i }))
    
    await waitFor(() => {
      expect(screen.getByText('Item 1')).toBeInTheDocument()
    })
  })
})

Testing Mutations

it('should submit form and show success', async () => {
  const user = userEvent.setup()
  mockedApi.createItem.mockResolvedValue({ id: '1', name: 'New Item' })
  
  render(<CreateItemForm />)
  
  await user.type(screen.getByLabelText('Name'), 'New Item')
  await user.click(screen.getByRole('button', { name: /create/i }))
  
  // Button should be disabled during submission
  expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
  
  await waitFor(() => {
    expect(screen.getByText(/created successfully/i)).toBeInTheDocument()
  })
  
  expect(mockedApi.createItem).toHaveBeenCalledWith({ name: 'New Item' })
})

useEffect Testing

Testing Effect Execution

it('should fetch data on mount', async () => {
  const fetchData = jest.fn().mockResolvedValue({ data: 'test' })
  
  render(<ComponentWithEffect fetchData={fetchData} />)
  
  await waitFor(() => {
    expect(fetchData).toHaveBeenCalledTimes(1)
  })
})

Testing Effect Dependencies

it('should refetch when id changes', async () => {
  const fetchData = jest.fn().mockResolvedValue({ data: 'test' })
  
  const { rerender } = render(<ComponentWithEffect id="1" fetchData={fetchData} />)
  
  await waitFor(() => {
    expect(fetchData).toHaveBeenCalledWith('1')
  })
  
  rerender(<ComponentWithEffect id="2" fetchData={fetchData} />)
  
  await waitFor(() => {
    expect(fetchData).toHaveBeenCalledWith('2')
    expect(fetchData).toHaveBeenCalledTimes(2)
  })
})

Testing Effect Cleanup

it('should cleanup subscription on unmount', () => {
  const subscribe = jest.fn()
  const unsubscribe = jest.fn()
  subscribe.mockReturnValue(unsubscribe)
  
  const { unmount } = render(<SubscriptionComponent subscribe={subscribe} />)
  
  expect(subscribe).toHaveBeenCalledTimes(1)
  
  unmount()
  
  expect(unsubscribe).toHaveBeenCalledTimes(1)
})

Common Async Pitfalls

Don't: Forget to await

// Bad - test may pass even if assertion fails
it('should load data', () => {
  render(<Component />)
  waitFor(() => {
    expect(screen.getByText('Data')).toBeInTheDocument()
  })
})

// Good - properly awaited
it('should load data', async () => {
  render(<Component />)
  await waitFor(() => {
    expect(screen.getByText('Data')).toBeInTheDocument()
  })
})

Don't: Use multiple assertions in single waitFor

// Bad - if first assertion fails, won't know about second
await waitFor(() => {
  expect(screen.getByText('Title')).toBeInTheDocument()
  expect(screen.getByText('Description')).toBeInTheDocument()
})

// Good - separate waitFor or use findBy
const title = await screen.findByText('Title')
const description = await screen.findByText('Description')
expect(title).toBeInTheDocument()
expect(description).toBeInTheDocument()

Don't: Mix fake timers with real async

// Bad - fake timers don't work well with real Promises
jest.useFakeTimers()
await waitFor(() => {
  expect(screen.getByText('Data')).toBeInTheDocument()
}) // May timeout!

// Good - use runAllTimers or advanceTimersByTime
jest.useFakeTimers()
render(<Component />)
jest.runAllTimers()
expect(screen.getByText('Data')).toBeInTheDocument()