Тестирование форм с YupResolver

Связка React Hook Form и Yup часто используется для построения форм с декларативной валидацией. В этой архитектуре ответственность за управление состоянием формы лежит на React Hook Form, а правила проверки данных задаются через Yup-схемы. Связывающим звеном выступает YupResolver, который адаптирует Yup-схему к интерфейсу валидации React Hook Form.

При тестировании таких форм возникает несколько уровней проверки:

  • корректность самой Yup-схемы;
  • интеграция resolver с формой;
  • поведение UI при ошибках;
  • асинхронная валидация и побочные эффекты;
  • изоляция бизнес-логики от компонента.

Каждый уровень требует собственного подхода и набора инструментов.


Архитектурная модель валидации через YupResolver

YupResolver выполняет роль адаптера: принимает Yup-схему и возвращает функцию-резолвер, совместимую с React Hook Form. При сабмите формы данные проходят через pipeline:

  1. React Hook Form собирает значения полей.
  2. YupResolver передаёт данные в Yup-схему.
  3. Yup выполняет синхронную или асинхронную проверку.
  4. Результат преобразуется в формат ошибок RHF.
  5. Ошибки отображаются в состоянии формы.

В тестах важно учитывать, что YupResolver не содержит бизнес-логики. Он лишь трансформирует результаты.


Тестирование Yup-схем как отдельного модуля

Перед интеграционным тестированием формы целесообразно изолированно проверять Yup-схемы. Это снижает сложность тестов компонентов.

Пример схемы:

import * as Yup from 'yup';

export const schema = Yup.object({
  email: Yup.string().email('Invalid email').required('Required'),
  password: Yup.string().min(8, 'Too short').required('Required'),
});

Юнит-тесты схемы

import { schema } from './schema';

describe('Yup schema validation', () => {
  test('valid data passes', async () => {
    const validData = {
      email: 'test@mail.com',
      password: '12345678',
    };

    await expect(schema.validate(validData)).resolves.toEqual(validData);
  });

  test('invalid email fails', async () => {
    const invalidData = {
      email: 'bad-email',
      password: '12345678',
    };

    await expect(schema.validate(invalidData)).rejects.toThrow();
  });
});

Преимущество такого подхода — исключение React-слоя и YupResolver из уравнения, что позволяет быстрее находить ошибки в бизнес-валидации.


Интеграция YupResolver в форму

При подключении resolver к React Hook Form схема начинает участвовать в жизненном цикле формы.

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { schema } from './schema';

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: yupResolver(schema),
  });

  const onSub mit = (data) => {
    console.log(data);
  };

  return (
    <form onSub mit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      <p>{errors.email?.message}</p>

      <input type="password" {...register('password')} />
      <p>{errors.password?.message}</p>

      <button type="submit">Submit</button>
    </form>
  );
}

Подход к тестированию формы с YupResolver

Интеграционные тесты проверяют взаимодействие между UI и валидатором. Используется React Hook Form в связке с YupResolver, поэтому тесты должны имитировать поведение пользователя.

Основной инструмент — React Testing Library.


Проверка отображения ошибок валидации

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

describe('LoginForm validation', () => {
  test('shows validation errors on submit', async () => {
    render(<LoginForm />);

    fireEvent.change(screen.getByLabelText(/email/i), {
      target: { value: 'invalid' },
    });

    fireEvent.change(screen.getByLabelText(/password/i), {
      target: { value: '123' },
    });

    fireEvent.click(screen.getByText(/submit/i));

    expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
    expect(await screen.findByText(/too short/i)).toBeInTheDocument();
  });
});

В этом сценарии YupResolver вызывается автоматически через handleSubmit. Тест не взаимодействует с ним напрямую, что соответствует принципу чёрного ящика.


Асинхронная валидация и её тестирование

Yup поддерживает асинхронные проверки, например, проверку уникальности email.

const schema = Yup.object({
  email: Yup.string()
    .email()
    .test('unique', 'Email taken', async (value) => {
      const res = await fakeApiCheck(value);
      return res === true;
    }),
});

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

jest.mock('./api', () => ({
  fakeApiCheck: jest.fn(),
}));

import { fakeApiCheck } from './api';

test('async validation fails when email is taken', async () => {
  fakeApiCheck.mockResolvedValue(false);

  await expect(schema.validate({
    email: 'taken@mail.com',
  })).rejects.toThrow('Email taken');
});

Тестирование формы с моками API

При интеграции с сервером важно отделять сетевую логику от валидации.

Типичная схема тестирования:

  • Yup отвечает за синхронные правила;
  • асинхронные проверки мокируются;
  • UI проверяется отдельно.

Пример:

global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ exists: true }),
  })
);

Такой подход позволяет стабилизировать тесты и избежать флаки-поведения.


Проверка взаимодействия YupResolver с React Hook Form state

Важный аспект — состояние формы:

  • touchedFields
  • dirtyFields
  • errors
  • isValid
const { result } = renderHook(() =>
  useForm({
    resolver: yupResolver(schema),
    mode: 'onChange',
  })
);

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

act(() => {
  result.current.setValue('email', 'bad');
});

expect(result.current.formState.errors.email).toBeDefined();

Поведение resolver при разных режимах валидации

React Hook Form поддерживает режимы:

  • onSubmit
  • onChange
  • onBlur
  • all

YupResolver вызывается по-разному в зависимости от режима.

Проверка onChange

const { getByLabelText } = render(<LoginForm />);

fireEvent.change(getByLabelText(/email/i), {
  target: { value: 'x' },
});

// ошибка появляется сразу

Проверка onSubmit

fireEvent.click(screen.getByText(/submit/i));

// ошибки появляются только после submit

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


Изоляция YupResolver при unit-тестировании формы

Иногда требуется исключить Yup и проверить только поведение формы.

jest.mock('@hookform/resolvers/yup', () => ({
  yupResolver: () => () => ({ values: {}, errors: {} }),
}));

Такой подход используется редко, но полезен для:

  • проверки UI без валидации;
  • тестирования логики сабмита;
  • изоляции багов React Hook Form.

Типичные ошибки при тестировании YupResolver

1. Проверка внутренней логики resolver

Ошибка: попытка тестировать YupResolver напрямую вместо формы.

YupResolver не является бизнес-слоем, его поведение не должно проверяться отдельно в большинстве случаев.


2. Игнорирование асинхронности

Yup validate возвращает Promise, поэтому тесты без await приводят к ложноположительным результатам.


3. Неправильный выбор уровня теста

  • schema → unit
  • form → integration
  • UI → e2e

Смешивание уровней усложняет диагностику ошибок.


4. Отсутствие синхронизации act()

React Hook Form обновляет состояние асинхронно, поэтому обновления должны оборачиваться в act.


Поведение ошибок и их маппинг

Yup возвращает ошибки в структуре:

{
  "email": "Invalid email"
}

YupResolver трансформирует их в:

{
  errors: {
    email: {
      message: 'Invalid email'
    }
  }
}

В тестах важно проверять именно финальную структуру React Hook Form, а не Yup.


Стратегия покрытия тестами форм с YupResolver

Эффективная модель покрытия:

  • 60% — schema validation tests
  • 30% — integration tests формы
  • 10% — edge cases (async, conditional validation)

Такое распределение снижает зависимость от UI-тестов и ускоряет выполнение suite.


Проверка условной валидации

Yup поддерживает conditional schema:

Yup.string().when('role', {
  is: 'admin',
  then: (schema) => schema.required(),
});

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

test('admin requires password', async () => {
  await expect(
    schema.validate({ role: 'admin' })
  ).rejects.toThrow();
});

Поведение resolver при reset формы

React Hook Form сбрасывает состояние, но YupResolver не хранит state.

Тесты должны проверять:

  • очистку errors
  • возврат defaultValues
  • отсутствие остаточных значений
act(() => {
  result.current.reset();
});

expect(result.current.formState.errors).toEqual({});

Особенности тестирования больших форм

Для сложных форм с десятками полей:

  • разбиваются секции
  • каждая секция тестируется отдельно
  • общая интеграция проверяет только критические сценарии

YupResolver в таких случаях остаётся неизменным посредником, не влияющим на стратегию тестирования, но определяющим формат ошибок и момент их появления.