← React/Продвинутое тестирование React-компонентов#289 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Продвинутое тестирование React-компонентов

Тестирование асинхронных компонентов

Компоненты с data fetching требуют особого подхода. После render нужно дождаться завершения всех async операций:

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

// Компонент с async загрузкой:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    api.getUser(userId).then(u => {
      setUser(u)
      setIsLoading(false)
    })
  }, [userId])

  if (isLoading) return <div>Загрузка...</div>
  return <div data-testid="user-name">{user.name}</div>
}

// Тест:
test('загружает и отображает пользователя', async () => {
  // Мокаем API:
  jest.spyOn(api, 'getUser').mockResolvedValue({ id: 1, name: 'Алексей' })

  render(<UserProfile userId={1} />)

  // 1. Проверяем состояние загрузки:
  expect(screen.getByText('Загрузка...')).toBeInTheDocument()

  // 2. Ждём появления данных:
  const userName = await screen.findByTestId('user-name')  // findBy = автоматический waitFor
  expect(userName).toHaveTextContent('Алексей')

  // 3. Проверяем что loading исчез:
  expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})

waitFor vs findBy

// findBy* — сочетает getBy + waitFor (рекомендуется)
const element = await screen.findByText('Загружено')

// waitFor — для более сложных ожиданий
await waitFor(() => {
  expect(screen.getByText('Готово')).toBeInTheDocument()
  expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})

// waitFor с таймаутом:
await waitFor(
  () => expect(screen.getByText('Готово')).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
)

MSW: Mock Service Worker

MSW перехватывает реальные HTTP-запросы (работает на уровне Service Worker или Node.js). Лучше чем мокать fetch/axios напрямую:

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Алексей' })
  }),

  http.get('/api/users/:id/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'Первый пост' },
      { id: 2, title: 'Второй пост' },
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// Тест ошибки — переопределяем handler:
test('показывает ошибку', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 404 })
    })
  )

  render(<UserProfile userId={999} />)
  await screen.findByText('Пользователь не найден')
})

Тестирование хуков с renderHook

import { renderHook, act } from '@testing-library/react'

// Хук для тестирования:
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 }
}

// Тест хука:
test('useCounter: инкремент и декремент', () => {
  const { result } = renderHook(() => useCounter(10))

  expect(result.current.count).toBe(10)

  act(() => result.current.increment())
  expect(result.current.count).toBe(11)

  act(() => result.current.decrement())
  act(() => result.current.decrement())
  expect(result.current.count).toBe(9)

  act(() => result.current.reset())
  expect(result.current.count).toBe(10)
})

// Тест хука с провайдером контекста:
test('useAuth: с провайдером', () => {
  const wrapper = ({ children }) => (
    <AuthProvider initialUser={{ name: 'Алексей' }}>
      {children}
    </AuthProvider>
  )

  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current.user.name).toBe('Алексей')
})

Тестирование компонентов с контекстом

// Утилита для рендеринга с провайдерами:
function renderWithProviders(ui, { user = null } = {}) {
  return render(
    <AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
      <QueryClientProvider client={new QueryClient()}>
        <MemoryRouter>
          {ui}
        </MemoryRouter>
      </QueryClientProvider>
    </AuthContext.Provider>
  )
}

// Использование:
test('авторизованный пользователь видит кнопку', () => {
  renderWithProviders(<Toolbar />, {
    user: { id: 1, name: 'Алексей', role: 'admin' }
  })

  expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument()
})

Организация тестов

// describe/it структура:
describe('UserProfile', () => {
  describe('состояния загрузки', () => {
    it('показывает спиннер при загрузке', () => { /* ... */ })
    it('скрывает спиннер после загрузки', async () => { /* ... */ })
  })

  describe('успешная загрузка', () => {
    it('отображает имя пользователя', async () => { /* ... */ })
    it('отображает аватар', async () => { /* ... */ })
  })

  describe('обработка ошибок', () => {
    it('показывает сообщение при 404', async () => { /* ... */ })
    it('показывает кнопку повтора при ошибке', async () => { /* ... */ })
  })
})

Примеры

Реализация async тест-хелперов: waitFor с таймаутом и интервалом, mock fetch, renderHook симуляция, тесты loading/error/success состояний

// Реализуем тест-хелперы для асинхронного тестирования без тестового фреймворка.

// --- waitFor: ждём выполнения условия ---

async function waitFor(assertion, options = {}) {
  const { timeout = 1000, interval = 50 } = options
  const startTime = Date.now()

  while (true) {
    try {
      assertion()  // бросает если условие не выполнено
      return true  // успех
    } catch (error) {
      if (Date.now() - startTime >= timeout) {
        throw new Error(
          'waitFor timeout после ' + timeout + 'мс: ' + error.message
        )
      }
      // Ждём следующей проверки
      await new Promise(resolve => setTimeout(resolve, interval))
    }
  }
}

// --- Mock Fetch ---

function createMockFetch(handlers) {
  const callLog = []

  const mockFetch = async (url, options = {}) => {
    const method = (options.method || 'GET').toUpperCase()
    callLog.push({ url, method, timestamp: Date.now() })

    // Ищем подходящий handler
    const handler = handlers.find(h => {
      const methodMatch = !h.method || h.method === method
      const urlMatch = typeof h.url === 'string'
        ? url.includes(h.url)
        : h.url.test(url)
      return methodMatch && urlMatch
    })

    if (!handler) {
      return { ok: false, status: 404, json: async () => ({ error: 'Not Found' }) }
    }

    // Симулируем задержку сети
    if (handler.delay) {
      await new Promise(r => setTimeout(r, handler.delay))
    }

    if (handler.status >= 400) {
      return { ok: false, status: handler.status, json: async () => handler.body }
    }

    return { ok: true, status: 200, json: async () => handler.body }
  }

  mockFetch.getCalls = () => [...callLog]
  mockFetch.getCallCount = () => callLog.length
  mockFetch.wasCalledWith = (url) => callLog.some(c => c.url.includes(url))

  return mockFetch
}

// --- Симуляция компонента с fetch ---

function createAsyncComponent(fetchFn) {
  let state = { status: 'idle', data: null, error: null }
  const listeners = []

  function setState(updates) {
    state = { ...state, ...updates }
    listeners.forEach(fn => fn(state))
  }

  async function load(id) {
    setState({ status: 'loading', error: null })

    try {
      const res = await fetchFn('/api/users/' + id)
      if (!res.ok) throw new Error('HTTP ' + res.status)
      const data = await res.json()
      setState({ status: 'success', data })
    } catch (error) {
      setState({ status: 'error', error: error.message, data: null })
    }
  }

  return {
    load,
    getState: () => ({ ...state }),
    subscribe: (fn) => listeners.push(fn),
  }
}

// --- Тесты ---

async function runTests() {
  console.log('=== Тест 1: waitFor базовое использование ===')

  let value = 0
  setTimeout(() => { value = 42 }, 100)

  await waitFor(() => {
    if (value !== 42) throw new Error('Ожидаем 42, получили ' + value)
  })
  console.log('waitFor: value достиг 42 ✓')

  // --- Тест 2: waitFor timeout ---

  console.log('
=== Тест 2: waitFor timeout ===')
  try {
    await waitFor(
      () => { throw new Error('никогда не выполнится') },
      { timeout: 100, interval: 20 }
    )
  } catch (err) {
    console.log('Timeout поймали:', err.message.includes('timeout') ? '✓' : '✗')
  }

  // --- Тест 3: Успешная загрузка ---

  console.log('
=== Тест 3: Успешная загрузка ===')

  const mockFetch = createMockFetch([
    { url: '/api/users', method: 'GET', delay: 50, status: 200,
      body: { id: 1, name: 'Алексей', role: 'Разработчик' } }
  ])

  const component = createAsyncComponent(mockFetch)
  const stateHistory = []
  component.subscribe(s => stateHistory.push(s.status))

  component.load(1)

  // Сразу должно быть loading
  await waitFor(() => {
    if (component.getState().status !== 'loading')
      throw new Error('Ожидаем loading')
  })
  console.log('Loading state: ✓')

  // Ждём success
  await waitFor(() => {
    if (component.getState().status !== 'success')
      throw new Error('Ожидаем success')
  })

  const state = component.getState()
  console.log('Success state: ✓')
  console.log('Данные:', state.data.name)  // Алексей

  // --- Тест 4: Ошибка ---

  console.log('
=== Тест 4: Обработка ошибки 404 ===')

  const errorFetch = createMockFetch([
    { url: '/api/users', method: 'GET', delay: 20, status: 404, body: { error: 'Not Found' } }
  ])

  const component2 = createAsyncComponent(errorFetch)
  component2.load(999)

  await waitFor(() => {
    if (component2.getState().status !== 'error')
      throw new Error('Ожидаем error')
  })

  console.log('Error state: ✓')
  console.log('Сообщение ошибки:', component2.getState().error)  // 'HTTP 404'

  // --- Тест 5: Mock Fetch логирование ---

  console.log('
=== Тест 5: Mock Fetch лог вызовов ===')
  console.log('Вызовов к errorFetch:', errorFetch.getCallCount())  // 1
  console.log('Был вызван с /api/users:', errorFetch.wasCalledWith('/api/users'))
  console.log('История вызовов:', errorFetch.getCalls().map(c => c.method + ' ' + c.url))
}

runTests()

Продвинутое тестирование React-компонентов

Тестирование асинхронных компонентов

Компоненты с data fetching требуют особого подхода. После render нужно дождаться завершения всех async операций:

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

// Компонент с async загрузкой:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null)
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    api.getUser(userId).then(u => {
      setUser(u)
      setIsLoading(false)
    })
  }, [userId])

  if (isLoading) return <div>Загрузка...</div>
  return <div data-testid="user-name">{user.name}</div>
}

// Тест:
test('загружает и отображает пользователя', async () => {
  // Мокаем API:
  jest.spyOn(api, 'getUser').mockResolvedValue({ id: 1, name: 'Алексей' })

  render(<UserProfile userId={1} />)

  // 1. Проверяем состояние загрузки:
  expect(screen.getByText('Загрузка...')).toBeInTheDocument()

  // 2. Ждём появления данных:
  const userName = await screen.findByTestId('user-name')  // findBy = автоматический waitFor
  expect(userName).toHaveTextContent('Алексей')

  // 3. Проверяем что loading исчез:
  expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})

waitFor vs findBy

// findBy* — сочетает getBy + waitFor (рекомендуется)
const element = await screen.findByText('Загружено')

// waitFor — для более сложных ожиданий
await waitFor(() => {
  expect(screen.getByText('Готово')).toBeInTheDocument()
  expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})

// waitFor с таймаутом:
await waitFor(
  () => expect(screen.getByText('Готово')).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
)

MSW: Mock Service Worker

MSW перехватывает реальные HTTP-запросы (работает на уровне Service Worker или Node.js). Лучше чем мокать fetch/axios напрямую:

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Алексей' })
  }),

  http.get('/api/users/:id/posts', () => {
    return HttpResponse.json([
      { id: 1, title: 'Первый пост' },
      { id: 2, title: 'Второй пост' },
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// Тест ошибки — переопределяем handler:
test('показывает ошибку', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 404 })
    })
  )

  render(<UserProfile userId={999} />)
  await screen.findByText('Пользователь не найден')
})

Тестирование хуков с renderHook

import { renderHook, act } from '@testing-library/react'

// Хук для тестирования:
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 }
}

// Тест хука:
test('useCounter: инкремент и декремент', () => {
  const { result } = renderHook(() => useCounter(10))

  expect(result.current.count).toBe(10)

  act(() => result.current.increment())
  expect(result.current.count).toBe(11)

  act(() => result.current.decrement())
  act(() => result.current.decrement())
  expect(result.current.count).toBe(9)

  act(() => result.current.reset())
  expect(result.current.count).toBe(10)
})

// Тест хука с провайдером контекста:
test('useAuth: с провайдером', () => {
  const wrapper = ({ children }) => (
    <AuthProvider initialUser={{ name: 'Алексей' }}>
      {children}
    </AuthProvider>
  )

  const { result } = renderHook(() => useAuth(), { wrapper })
  expect(result.current.user.name).toBe('Алексей')
})

Тестирование компонентов с контекстом

// Утилита для рендеринга с провайдерами:
function renderWithProviders(ui, { user = null } = {}) {
  return render(
    <AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
      <QueryClientProvider client={new QueryClient()}>
        <MemoryRouter>
          {ui}
        </MemoryRouter>
      </QueryClientProvider>
    </AuthContext.Provider>
  )
}

// Использование:
test('авторизованный пользователь видит кнопку', () => {
  renderWithProviders(<Toolbar />, {
    user: { id: 1, name: 'Алексей', role: 'admin' }
  })

  expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument()
})

Организация тестов

// describe/it структура:
describe('UserProfile', () => {
  describe('состояния загрузки', () => {
    it('показывает спиннер при загрузке', () => { /* ... */ })
    it('скрывает спиннер после загрузки', async () => { /* ... */ })
  })

  describe('успешная загрузка', () => {
    it('отображает имя пользователя', async () => { /* ... */ })
    it('отображает аватар', async () => { /* ... */ })
  })

  describe('обработка ошибок', () => {
    it('показывает сообщение при 404', async () => { /* ... */ })
    it('показывает кнопку повтора при ошибке', async () => { /* ... */ })
  })
})

Примеры

Реализация async тест-хелперов: waitFor с таймаутом и интервалом, mock fetch, renderHook симуляция, тесты loading/error/success состояний

// Реализуем тест-хелперы для асинхронного тестирования без тестового фреймворка.

// --- waitFor: ждём выполнения условия ---

async function waitFor(assertion, options = {}) {
  const { timeout = 1000, interval = 50 } = options
  const startTime = Date.now()

  while (true) {
    try {
      assertion()  // бросает если условие не выполнено
      return true  // успех
    } catch (error) {
      if (Date.now() - startTime >= timeout) {
        throw new Error(
          'waitFor timeout после ' + timeout + 'мс: ' + error.message
        )
      }
      // Ждём следующей проверки
      await new Promise(resolve => setTimeout(resolve, interval))
    }
  }
}

// --- Mock Fetch ---

function createMockFetch(handlers) {
  const callLog = []

  const mockFetch = async (url, options = {}) => {
    const method = (options.method || 'GET').toUpperCase()
    callLog.push({ url, method, timestamp: Date.now() })

    // Ищем подходящий handler
    const handler = handlers.find(h => {
      const methodMatch = !h.method || h.method === method
      const urlMatch = typeof h.url === 'string'
        ? url.includes(h.url)
        : h.url.test(url)
      return methodMatch && urlMatch
    })

    if (!handler) {
      return { ok: false, status: 404, json: async () => ({ error: 'Not Found' }) }
    }

    // Симулируем задержку сети
    if (handler.delay) {
      await new Promise(r => setTimeout(r, handler.delay))
    }

    if (handler.status >= 400) {
      return { ok: false, status: handler.status, json: async () => handler.body }
    }

    return { ok: true, status: 200, json: async () => handler.body }
  }

  mockFetch.getCalls = () => [...callLog]
  mockFetch.getCallCount = () => callLog.length
  mockFetch.wasCalledWith = (url) => callLog.some(c => c.url.includes(url))

  return mockFetch
}

// --- Симуляция компонента с fetch ---

function createAsyncComponent(fetchFn) {
  let state = { status: 'idle', data: null, error: null }
  const listeners = []

  function setState(updates) {
    state = { ...state, ...updates }
    listeners.forEach(fn => fn(state))
  }

  async function load(id) {
    setState({ status: 'loading', error: null })

    try {
      const res = await fetchFn('/api/users/' + id)
      if (!res.ok) throw new Error('HTTP ' + res.status)
      const data = await res.json()
      setState({ status: 'success', data })
    } catch (error) {
      setState({ status: 'error', error: error.message, data: null })
    }
  }

  return {
    load,
    getState: () => ({ ...state }),
    subscribe: (fn) => listeners.push(fn),
  }
}

// --- Тесты ---

async function runTests() {
  console.log('=== Тест 1: waitFor базовое использование ===')

  let value = 0
  setTimeout(() => { value = 42 }, 100)

  await waitFor(() => {
    if (value !== 42) throw new Error('Ожидаем 42, получили ' + value)
  })
  console.log('waitFor: value достиг 42 ✓')

  // --- Тест 2: waitFor timeout ---

  console.log('
=== Тест 2: waitFor timeout ===')
  try {
    await waitFor(
      () => { throw new Error('никогда не выполнится') },
      { timeout: 100, interval: 20 }
    )
  } catch (err) {
    console.log('Timeout поймали:', err.message.includes('timeout') ? '✓' : '✗')
  }

  // --- Тест 3: Успешная загрузка ---

  console.log('
=== Тест 3: Успешная загрузка ===')

  const mockFetch = createMockFetch([
    { url: '/api/users', method: 'GET', delay: 50, status: 200,
      body: { id: 1, name: 'Алексей', role: 'Разработчик' } }
  ])

  const component = createAsyncComponent(mockFetch)
  const stateHistory = []
  component.subscribe(s => stateHistory.push(s.status))

  component.load(1)

  // Сразу должно быть loading
  await waitFor(() => {
    if (component.getState().status !== 'loading')
      throw new Error('Ожидаем loading')
  })
  console.log('Loading state: ✓')

  // Ждём success
  await waitFor(() => {
    if (component.getState().status !== 'success')
      throw new Error('Ожидаем success')
  })

  const state = component.getState()
  console.log('Success state: ✓')
  console.log('Данные:', state.data.name)  // Алексей

  // --- Тест 4: Ошибка ---

  console.log('
=== Тест 4: Обработка ошибки 404 ===')

  const errorFetch = createMockFetch([
    { url: '/api/users', method: 'GET', delay: 20, status: 404, body: { error: 'Not Found' } }
  ])

  const component2 = createAsyncComponent(errorFetch)
  component2.load(999)

  await waitFor(() => {
    if (component2.getState().status !== 'error')
      throw new Error('Ожидаем error')
  })

  console.log('Error state: ✓')
  console.log('Сообщение ошибки:', component2.getState().error)  // 'HTTP 404'

  // --- Тест 5: Mock Fetch логирование ---

  console.log('
=== Тест 5: Mock Fetch лог вызовов ===')
  console.log('Вызовов к errorFetch:', errorFetch.getCallCount())  // 1
  console.log('Был вызван с /api/users:', errorFetch.wasCalledWith('/api/users'))
  console.log('История вызовов:', errorFetch.getCalls().map(c => c.method + ' ' + c.url))
}

runTests()

Задание

Создай компонент UserProfile с тремя состояниями (loading, success, error) и напиши для него тестовые проверки. Компонент загружает данные пользователя асинхронно. Заполни пропуски (???) для: проверки loading состояния, отображения имени пользователя после загрузки, обработки ошибки.

Подсказка

Для проверки loading: state.status === "loading". Для отображения ошибки: state.error. Для отображения имени: state.user.name.

Загружаем среду выполнения...
Загружаем AI-помощника...