API запросы в компонентах

В современных приложениях React часто возникает необходимость взаимодействовать с внешними API. Компоненты получают данные асинхронно и отображают их пользователю. Тестирование таких компонентов требует особого подхода, чтобы корректно проверять асинхронные операции и поведение интерфейса при разных сценариях ответа сервера.


Асинхронные компоненты и useEffect

Компоненты, делающие запросы к API, обычно используют хук useEffect для выполнения запросов после монтирования. Пример базового паттерна:

import React, { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Загрузка...</p>;
  if (error) return <p>Ошибка: {error.message}</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

export default UserList;

Ключевые моменты:

  • useEffect выполняется после первого рендера.
  • loading и error позволяют компоненту корректно реагировать на состояние запроса.
  • Данные API сохраняются в state с помощью useState.

Мокирование API запросов

Для тестирования компонентов важно изолировать их от реального API. Используются моки. Наиболее распространенные подходы:

  1. Jest и глобальный fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve([{ id: 1, name: 'Alice' }])
  })
);
  1. Библиотеки типа msw (Mock Service Worker)

msw позволяет подменять запросы на уровне сети, что делает тесты ближе к реальности без вызова настоящего сервера.

import { rest } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json([{ id: 1, name: 'Alice' }]));
  })
);

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

Тестирование асинхронного рендера

React Testing Library предоставляет утилиты для работы с асинхронным DOM:

  • findBy* – возвращает промис, который резолвится, когда элемент появляется.
  • waitFor – ожидает выполнение колбэка до успешного завершения или таймаута.

Пример теста компонента UserList:

import { render, screen } from '@testing-library/react';
import UserList from './UserList';

test('отображает список пользователей после загрузки', async () => {
  render(<UserList />);

  expect(screen.getByText(/Загрузка/i)).toBeInTheDocument();

  const userItem = await screen.findByText('Alice');
  expect(userItem).toBeInTheDocument();
});

Особенности:

  • Сначала проверяется отображение индикатора загрузки.
  • findByText автоматически ждет появления элемента в DOM.
  • Нет необходимости использовать act вручную — RTL оборачивает асинхронные действия.

Проверка ошибок API

Тесты должны учитывать ошибки сети или некорректные ответы:

test('отображает сообщение об ошибке при неудачном запросе', async () => {
  global.fetch.mockImplementationOnce(() =>
    Promise.reject(new Error('Network Error'))
  );

  render(<UserList />);

  const errorMessage = await screen.findByText(/Ошибка/i);
  expect(errorMessage).toBeInTheDocument();
});

Подход позволяет гарантировать корректное поведение интерфейса при сбоях.


Изоляция логики API

Для упрощения тестирования рекомендуется выносить логику запросов в отдельные функции или сервисы:

export const fetchUsers = async () => {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Ошибка запроса');
  return res.json();
};

Компонент становится проще:

useEffect(() => {
  fetchUsers()
    .then(setUsers)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

Преимущества:

  • Тестируются отдельные функции без рендера компонентов.
  • Компоненты тестируются как чистые UI-элементы.
  • Легко подменять моки только для конкретного сервиса.

Комбинирование событий и API

Компоненты часто обновляют данные по действиям пользователя, например, при клике кнопки:

function RefreshButton({ onRefresh }) {
  return <button onCl ick={onRefresh}>Обновить</button>;
}

function UserListWithRefresh() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);

  const loadUsers = async () => {
    setLoading(true);
    const data = await fetchUsers();
    setUsers(data);
    setLoading(false);
  };

  useEffect(() => { loadUsers(); }, []);

  return (
    <>
      <RefreshButton onRefr esh={loadUsers} />
      {loading ? <p>Загрузка...</p> : (
        <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
      )}
    </>
  );
}

Тест такого компонента:

test('обновляет список при нажатии кнопки', async () => {
  render(<UserListWithRefresh />);

  const button = screen.getByText('Обновить');

  global.fetch.mockImplementationOnce(() =>
    Promise.resolve({ json: () => Promise.resolve([{ id: 2, name: 'Bob' }]) })
  );

  button.click();

  const newUser = await screen.findByText('Bob');
  expect(newUser).toBeInTheDocument();
});

Особенности:

  • Асинхронные действия могут повторяться несколько раз.
  • Использование mockImplementationOnce позволяет контролировать последовательные ответы API.

Важные практики при тестировании API-компонентов

  • Всегда тестировать loading, success и error состояния.
  • Изолировать API-запросы в отдельные сервисы.
  • Использовать findBy* и waitFor вместо синхронных getBy* для асинхронного рендера.
  • Мокировать сетевые запросы для стабильных и быстрых тестов.
  • Проверять обновления UI при пользовательских действиях и повторных запросах.

Эти подходы позволяют создавать надежные, масштабируемые тесты для компонентов, взаимодействующих с API, без зависимости от реальной сети и серверной инфраструктуры.