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

В React useState является базовым хуком для управления локальным состоянием компонентов. Его тестирование требует внимания к изменениям состояния и корректной отрисовке UI в ответ на эти изменения. React Testing Library (RTL) предоставляет удобные средства для работы с компонентами, ориентируясь на пользовательское поведение вместо внутренней реализации.


Основы тестирования состояния

useState управляет значениями внутри функционального компонента. Для тестирования важно проверять изменения DOM, которые происходят в ответ на обновление состояния, а не само состояние напрямую.

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

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onCl ick={() => setCount(prev => prev + 1)}>Increment</button>
    </div>
  );
}

export default Counter;

Основная цель теста — убедиться, что при клике на кнопку значение счетчика увеличивается, а UI обновляется.


Использование render и screen

RTL предоставляет функцию render для отрисовки компонента и screen для поиска элементов по тексту, ролям или тестовым идентификаторам.

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

test('увеличивает счетчик при клике', () => {
  render(<Counter />);
  
  const count = screen.getByTestId('count');
  const button = screen.getByText('Increment');

  expect(count.textContent).toBe('0');
  
  fireEvent.click(button);
  
  expect(count.textContent).toBe('1');
});

Ключевой момент: тест проверяет конечный результат в DOM, а не сам useState. Это соответствует принципам RTL: ориентироваться на поведение пользователя.


Альтернатива: userEvent

fireEvent симулирует события низкого уровня, но для более реалистичного поведения предпочтительнее использовать userEvent, который имитирует действия пользователя:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('увеличивает счетчик при клике с userEvent', async () => {
  render(<Counter />);
  const count = screen.getByTestId('count');
  const button = screen.getByText('Increment');

  expect(count).toHaveTextContent('0');
  
  await userEvent.click(button);
  expect(count).toHaveTextContent('1');
});

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


Тестирование сложных состояний

Компоненты часто используют несколько useState или сложные объекты в состоянии. В этом случае важно проверять все изменения DOM, которые зависят от состояния:

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');

  const addTodo = () => {
    setTodos([...todos, input]);
    setInput('');
  };

  return (
    <div>
      <input
        value={input}
        onCha nge={e => setInput(e.target.value)}
        placeholder="New todo"
      />
      <button onCl ick={addTodo}>Add</button>
      <ul>
        {todos.map((todo, idx) => <li key={idx}>{todo}</li>)}
      </ul>
    </div>
  );
}

Тестирование:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';

test('добавляет новый элемент в список', async () => {
  render(<TodoList />);
  
  const input = screen.getByPlaceholderText('New todo');
  const button = screen.getByText('Add');

  await userEvent.type(input, 'Buy milk');
  await userEvent.click(button);

  const todoItem = screen.getByText('Buy milk');
  expect(todoItem).toBeInTheDocument();
  expect(input).toHaveValue('');
});

Особое внимание уделяется очистке input после добавления и корректной отрисовке нового элемента. Это демонстрирует проверку нескольких состояний одновременно.


Проверка асинхронного обновления состояния

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

function AsyncCounter() {
  const [count, setCount] = useState(0);

  const incrementAsync = () => {
    setTimeout(() => setCount(prev => prev + 1), 500);
  };

  return (
    <div>
      <span>{count}</span>
      <button onCl ick={incrementAsync}>Increment Async</button>
    </div>
  );
}

test('асинхронное увеличение счетчика', async () => {
  render(<AsyncCounter />);
  
  const button = screen.getByText('Increment Async');
  await userEvent.click(button);

  const updatedCount = await screen.findByText('1');
  expect(updatedCount).toBeInTheDocument();
});

findBy автоматически ждет появления элемента в DOM, что упрощает тестирование асинхронного поведения.


Практические рекомендации

  • Всегда ориентироваться на результат в DOM, а не на внутреннее состояние.
  • Использовать data-testid только там, где нет возможности выбрать элемент по роли или тексту.
  • Для асинхронных действий применять waitFor, findBy или userEvent.
  • Проверять все ключевые точки состояния, особенно при работе с массивами или объектами в useState.
  • Разделять тесты по поведению: отдельные тесты для разных сценариев обновления состояния.

Взаимодействие нескольких состояний

Если компонент использует несколько хуков useState, важно проверять их взаимодействие:

function MultiState() {
  const [text, setText] = useState('');
  const [enabled, setEnabled] = useState(false);

  return (
    <div>
      <input value={text} onCha nge={e => setText(e.target.value)} />
      <button onCl ick={() => setEnabled(prev => !prev)}>Toggle</button>
      {enabled && <span>{text}</span>}
    </div>
  );
}

test('показывает текст только когда включено', async () => {
  render(<MultiState />);
  
  const input = screen.getByRole('textbox');
  const toggle = screen.getByText('Toggle');

  await userEvent.type(input, 'Hello');
  expect(screen.queryByText('Hello')).not.toBeInTheDocument();

  await userEvent.click(toggle);
  expect(screen.getByText('Hello')).toBeInTheDocument();
});

Тест показывает, как состояния могут зависеть друг от друга и как важно проверять условную отрисовку.


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