Понимание асинхронности в JavaScript и React

Асинхронное выполнение кода является фундаментальной частью современного JavaScript. Оно позволяет не блокировать основной поток исполнения, обеспечивая отзывчивость интерфейсов и эффективное взаимодействие с внешними ресурсами. В React асинхронность особенно важна при работе с данными, сетевыми запросами и событиями, требующими отложенного выполнения.


Промисы и async/await

Промис — объект, представляющий результат асинхронной операции, который может быть в состоянии pending, fulfilled или rejected. Основные методы работы с промисами:

fetch('/api/data')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

async/await позволяет писать асинхронный код в синхронном стиле, улучшая читаемость:

async function fetchData() {
  try {
    const response = await fetch('/api/data');
    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error(error);
  }
}

В контексте React async/await часто используется внутри эффектов:

useEffect(() => {
  async function loadData() {
    const response = await fetch('/api/data');
    const result = await response.json();
    setData(result);
  }
  loadData();
}, []);

Асинхронность в тестировании React

React Testing Library ориентирована на тестирование компонентов так, как они используются пользователями, что подразумевает работу с асинхронными эффектами, сетевыми запросами и событиями интерфейса.

Основные подходы

  1. waitFor — ожидание определенного состояния компонента:
import { render, screen, waitFor } from '@testing-library/react';
import MyComponent from './MyComponent';

test('загрузка данных и отображение', async () => {
  render(<MyComponent />);
  
  await waitFor(() => expect(screen.getByText('Данные загружены')).toBeInTheDocument());
});

waitFor повторяет проверку до тех пор, пока условие не выполнится или не истечет таймаут.

  1. findBy — сокращенная форма ожидания элемента:
const element = await screen.findByText('Данные загружены');
expect(element).toBeInTheDocument();

findBy автоматически использует внутренний waitFor, поэтому не требуется дополнительная обертка.

  1. Асинхронные события. События пользователя часто запускают асинхронные операции:
import userEvent from '@testing-library/user-event';

test('нажатие кнопки вызывает загрузку данных', async () => {
  render(<MyComponent />);
  
  const button = screen.getByRole('button', { name: /загрузить/i });
  await userEvent.click(button);

  expect(await screen.findByText('Данные загружены')).toBeInTheDocument();
});

userEvent.click может быть асинхронным, если вызывает setState или эффект, поэтому используется await.


Важные нюансы при тестировании асинхронного кода

  • Мокирование запросов: Реальные HTTP-запросы замедляют тесты и создают внешние зависимости. Используются библиотеки msw или jest.fn() для создания моков:
global.fetch = jest.fn(() =>
  Promise.resolve({ json: () => Promise.resolve({ message: 'OK' }) })
);
  • Обновления состояния в React выполняются асинхронно. Проверки нужно делать после завершения обновлений, используя waitFor или findBy.

  • Таймауты и ошибка ожидания: по умолчанию waitFor и findBy ждут до 1000 мс. Можно изменить через параметр { timeout: 3000 }.

  • Сброс моков и состояний между тестами:

afterEach(() => {
  jest.clearAllMocks();
});

Это предотвращает взаимное влияние тестов и обеспечивает независимость.


Асинхронность с хуками

React-хуки часто используются с асинхронным кодом:

  • useEffect — для загрузки данных после рендера.
  • useState — хранение результатов асинхронных операций.
  • useReducer — управление сложными состояниями с различными статусами (loading, success, error).

Пример использования useReducer для асинхронной загрузки:

function dataReducer(state, action) {
  switch (action.type) {
    case 'LOADING':
      return { ...state, loading: true };
    case 'SUCCESS':
      return { loading: false, data: action.payload, error: null };
    case 'ERROR':
      return { loading: false, data: null, error: action.payload };
    default:
      return state;
  }
}

function MyComponent() {
  const [state, dispatch] = useReducer(dataReducer, { loading: false, data: null, error: null });

  useEffect(() => {
    async function fetchData() {
      dispatch({ type: 'LOADING' });
      try {
        const response = await fetch('/api/data');
        const data = await response.json();
        dispatch({ type: 'SUCCESS', payload: data });
      } catch (error) {
        dispatch({ type: 'ERROR', payload: error });
      }
    }
    fetchData();
  }, []);

  if (state.loading) return <div>Загрузка...</div>;
  if (state.error) return <div>Ошибка!</div>;
  return <div>{state.data?.message}</div>;
}

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

test('успешная загрузка данных', async () => {
  render(<MyComponent />);
  expect(screen.getByText('Загрузка...')).toBeInTheDocument();
  expect(await screen.findByText('OK')).toBeInTheDocument();
});

Принципы надежного тестирования асинхронного React-кода

  1. Тестировать поведение, а не реализацию. Проверять конечный результат на UI, а не внутренние вызовы setState.
  2. Избегать использования act() вручную, если применяются findBy или waitFor, так как RTL автоматически оборачивает асинхронные операции в act().
  3. Минимизировать задержки и использовать моки вместо реальных API.
  4. Явно обрабатывать ошибки асинхронных операций для проверки поведения компонентов при сбое.

Асинхронность в React тесно связана с обновлением состояния и эффектами. Понимание того, как правильно ожидать изменения DOM, использовать waitFor и findBy, а также мокировать внешние запросы, обеспечивает стабильные и предсказуемые тесты, которые отражают поведение приложения в реальном времени.