React 19: разделяем логику и разметку, тестируем с Shallowly и Vitest

—

от автора

В прошлой статье я показал короткий тест с передачей пропсов. Теперь разберу компонент чуть подробнее: сначала приведу код к тому виду, в котором мне удобно с ним работать, а потом проверю его через свою библиотеку Shallowly.

Начнём с панели профиля. Пока данные грузятся, она показывает сообщение. Когда профиль готов — передаёт имя в карточку. Входных пропсов у самой панели пока нет, усложнять пример ими не будем.

Исходный компонент

import { UserCard } from './UserCard';import { useProfile } from './useProfile';export function ProfilePanel() {  const state = useProfile();  if (state.status === 'loading') {    return <p role="status">Loading profile...</p>;  }  return (    <section aria-label="Profile">      <h1>Your profile</h1>      <UserCard name={state.profile.name} />    </section>  );}

useProfile возвращает либо { status: 'loading' }, либо { status: 'ready', profile }. В примере внутри него обычный useState с локальным профилем. Запросов к бэку здесь нет. UserCard принимает name и умеет раскрывать детали через свой useState.

Тесты и архитектура

Тесты не должны диктовать архитектуру проекта. Если ради удобства одной библиотеки приходится выворачивать рабочий код наизнанку, я бы сначала посмотрел на сам способ тестирования. Но это не мешает менять архитектуру в лучшую сторону. У более понятного кода и тесты часто получаются проще.

В нашей панели вместе живут разметка и подготовка данных для неё. Сейчас это несколько строк. В рабочем компоненте к ним легко добавляются вычисления, проверки прав, обработка ошибок. Всё это приходится читать вперемешку с JSX. Вынесем подготовку данных в хук, чтобы в компоненте было видно, какие элементы он собирает и что им передаёт.

  • Separation of Concerns — разделение задач. Подготовка данных живёт в хуке, разметка — в компоненте. В JSX больше не нужно разбираться в устройстве результата useProfile.

  • Single Responsibility Principle — более узкая ответственность. Изменение разметки относится к компоненту, изменение способа подготовки данных — к setup-хуку. Это разные причины менять код.

  • Инкапсуляция в custom hook. Хук возвращает то, что нужно конкретной панели: признак загрузки и готовые пропсы карточки. Детали источника данных остаются внутри. Такой вынос логики в custom hooks описан и в документации React.

Идею подсмотрел во Vue 3 с его setup. В React предлагаю использовать соглашение useSetup[Component]: название сразу обозначает роль хука — собрать логику и подготовить данные для конкретного компонента. Для ProfilePanel это будет useSetupProfilePanel. В самом компоненте оставляем разметку.

Выносим подготовку данных

Добавим useSetupProfilePanel.ts:

import { useProfile } from './useProfile';export function useSetupProfilePanel() {  const state = useProfile();  if (state.status === 'loading') {    return {      isLoading: true as const,      userCardProps: null,    };  }  return {    isLoading: false as const,    userCardProps: {      name: state.profile.name,    },  };}

Теперь исходный state.status превращается в понятный панели isLoading, а имя собирается в userCardProps. В состоянии загрузки пропсов карточки ещё нет. as const сохраняет для TypeScript значения true и false в качестве признаков двух вариантов результата. После проверки isLoading он понимает, что userCardProps уже не null.

В ProfilePanel.tsx остаётся:

import { UserCard } from './UserCard';import { useSetupProfilePanel } from './useSetupProfilePanel';export function ProfilePanel() {  const { isLoading, userCardProps } = useSetupProfilePanel();  if (isLoading) {    return <p role="status">Loading profile...</p>;  }  return (    <section aria-label="Profile">      <h1>Your profile</h1>      <UserCard {...userCardProps} />    </section>  );}

Теперь тестируем компонент

Входных пропсов у ProfilePanel нет: тест рендерит его как <ProfilePanel />. Данные приходят из useSetupProfilePanel, а панель передаёт их дальше — в UserCard через userCardProps. Вот эту передачу и проверим: имя из хука должно попасть в карточку. Заодно проверим заголовок, подпись секции и сообщение о загрузке.

В shallow-тесте замокаю useSetupProfilePanel и задам готовый результат. Как хук получил имя и почему решил, что загрузка закончилась, здесь меня не интересует: это проверим отдельно в тестах хука.

Компонент и хук написаны на TypeScript, тесты ниже — на JavaScript. ProfilePanel.shallow.test.jsx целиком:

import { shallow } from 'shallowly';import { describe, expect, it, vi } from 'vitest';import { ProfilePanel } from './ProfilePanel';import { UserCard } from './UserCard';import { useSetupProfilePanel } from './useSetupProfilePanel';vi.mock('./useSetupProfilePanel');/** * Задаёт готовый профиль для тестов, которым не нужен особый ответ хука. * * @returns {void} */const mockDefault = () => {  useSetupProfilePanel.mockReturnValue({    isLoading: false,    userCardProps: { name: 'имя по умолчанию' },  });};describe('🐛 spec ProfilePanel', () => {  it('🧪 задаёт подпись секции и текст заголовка', () => {    expect.hasAssertions();    //☣️ Arrange    mockDefault();    //🔥 Act    const wrapper = shallow(<ProfilePanel />);    //❓ Assert    expect(wrapper.find('section').prop('aria-label')).toBe('Profile');    expect(wrapper.find('h1').prop('children')).toBe('Your profile');  });  it('🧪 передаёт подготовленные пропсы в UserCard', () => {    expect.hasAssertions();    //☣️ Arrange    mockDefault();    const userCardProps = { name: 'имя из хука' };    useSetupProfilePanel.mockReturnValue({ isLoading: false, userCardProps });    //🔥 Act    const wrapper = shallow(<ProfilePanel />);    //❓ Assert    expect(wrapper.find(UserCard).props()).toEqual(userCardProps);  });  it('🧪 показывает сообщение о загрузке без карточки', () => {    expect.hasAssertions();    //☣️ Arrange    mockDefault();    useSetupProfilePanel.mockReturnValue({ isLoading: true, userCardProps: null });    //🔥 Act    const wrapper = shallow(<ProfilePanel />);    //❓ Assert    expect(wrapper.find('p').props()).toEqual({      role: 'status',      children: 'Loading profile...',    });    expect(wrapper.find(UserCard).exists()).toBe(false);  });});

В начале каждого теста вызываю mockDefault() — он задаёт исходное состояние моков. Если нужен другой ответ хука, меняю его прямо в Arrange этого теста.

Подготовку сценариев в beforeEach, beforeAll и уборку за ними в afterEach, afterAll здесь не прячу. Иначе читаешь тест, а часть условий находится где-то выше, иногда ещё и во вложенных describe. Чтобы понять, с какими данными он запускается, приходится собирать всё это по файлу. С mockDefault() общая подготовка тоже вынесена в функцию, но её вызов явно стоит в начале теста. Сразу видно, откуда начинать читать и что конкретный сценарий меняет.

Служебную очистку моков и DOM оставляем под капотом, в общих настройках. За сброс моков отвечает mockReset: true, за очистку DOM — общий vitest.setup.ts. В самом тесте остаются три явных шага: подготовили данные — Arrange, выполнили действие — Act, проверили результат — Assert.

Сначала проверили подпись секции и текст заголовка. В React это тоже пропсы элементов: aria-label и children. Потом нашли именно UserCard и сравнили переданные ему пропсы с userCardProps из замоканного хука.

В моке специально задаём другое имя — имя из хука. Если компонент вместо данных из хука начнёт передавать захардкоженную строку, тест упадёт. TypeScript этот косяк спокойно пропустит: строка же, всё по типам. То, что в карточке оказалось чужое имя, в его картину прекрасного не входит.

В последнем тесте поменяли результат setup-хука: идёт загрузка, пропсов карточки нет. Проверили role и children у сообщения, а также отсутствие UserCard.

vi.mock('./useSetupProfilePanel') автоматически подменяет экспортируемый хук мок-функцией. Её ответ задаём через useSetupProfilePanel.mockReturnValue(...). Vitest поднимает vi.mock до импортов, поэтому панель получает подменённый setup-хук.

У Shallowly здесь узкая работа: выполнить компонент и дать посмотреть на возвращённые React-элементы и их пропсы. Тело UserCard он не выполняет, её внутренний useState не запускает. Подменён и setup-хук, который при настоящем вызове дошёл бы до useState внутри useProfile. Это не полный React-рендер; про роль React в вызове компонентов и хуков есть отдельное пояснение в документации.

А подготовку данных и карточку?

Для useSetupProfilePanel есть два теста через renderHook. Там подменяется useProfile и проверяется, что из готового профиля получаются нужные пропсы, а во время загрузки — признак загрузки и null вместо них.

В shallow-тесты панели эта логика не переезжает. Тест компонента отвечает за то, куда попали подготовленные пропсы. Если позже в setup-хуке появится обработчик, его поведение тоже проверяется в тестах хука. В тесте компонента достаточно будет проверить, что функция передана нужному элементу.

В DOM-тесте панель рендерится с настоящими хуками и UserCard. Он проверяет, что кнопка карточки раскрывает и скрывает детали. Там уже работает React и меняется DOM. Проверка идёт в jsdom, настоящий браузер не запускается.

Забрать и запустить

Полный пример закреплён тегом profile-panel-article-v3, чтобы команды и ссылки не зависели от судьбы рабочей ветки:

git clone --branch profile-panel-article-v3 --single-branch https://github.com/bad4iz/unit-test-article.gitcd unit-test-article/examples/profile-panelnpm cinpm testnpm run typecheck

Подойдёт Node.js 22 от версии 22.12 или Node.js 24. Версии зависимостей закреплены: React 19.2.4, Shallowly 1.1.1 и Vitest 4.0.18. В примере шесть проверок: три shallow-теста панели, два теста setup-хука и один DOM-тест. Конфиги TypeScript и JSX уже внутри. npm test запускает JavaScript-тесты, а npm run typecheck проверяет TypeScript-код компонентов, хуков и конфигурации.

Исходный компонент сохранён в src/ProfilePanel.before.tsx, результат — в src/ProfilePanel.tsx и src/useSetupProfilePanel.ts. Можно открыть их рядом и посмотреть, что именно переехало при рефакторинге. Методы Shallowly описаны в русском справочнике API.

ссылка на оригинал статьи https://habr.com/ru/articles/1095628/