Тестирование асинхронной валидации

Асинхронная валидация в связке с Yup и YupResolver усложняет тестирование форм, поскольку результат проверки перестаёт быть детерминированным в момент вызова функции. Любая схема, использующая .test(), .when() с промисами или внешние запросы (например, проверка уникальности имени через API), требует управления временем выполнения и мокирования внешних зависимостей.

Ключевая особенность YupResolver в том, что он преобразует Yup-схему в функцию-резолвер, совместимую с системами управления формами вроде react-hook-form. Эта функция всегда возвращает Promise, даже если внутри схемы нет асинхронных правил. Это означает, что тестовая среда должна учитывать асинхронный характер выполнения на уровне резолвера, а не только схемы.


Любой вызов resolver возвращает структуру:

  • values — валидированные данные при успехе
  • errors — объект ошибок при невалидных данных

Даже синхронные проверки оборачиваются в Promise, что приводит к необходимости использования await в тестах. Типичная ошибка — попытка проверить результат без ожидания завершения промиса, что приводит к ложноположительным результатам.

Особенность проявляется особенно ярко при использовании кастомных асинхронных тестов Yup:

const schema = yup.object({
  username: yup
    .string()
    .required()
    .test(
      'check-username',
      'Имя уже занято',
      async (value) => {
        const res = await fetch(`/api/check?username=${value}`);
        const data = await res.json();
        return data.available;
      }
    )
});

В этом случае YupResolver не различает источник асинхронности — он всегда дожидается завершения всех промисов внутри схемы.


Базовая стратегия тестирования резолвера

Тестирование начинается с изоляции схемы и замены внешних вызовов. Основной инструмент — мокирование fetch, axios или любых сервисных функций.

global.fetch = jest.fn();

Далее важно учитывать, что вызов resolver сам по себе асинхронный:

const resolver = yupResolver(schema);

const result = await resolver({
  username: 'test'
}, {}, {});

Структура вызова зависит от интеграции, но принцип остаётся: всегда требуется await.


Тестирование успешной асинхронной валидации

Сценарий успешной проверки строится на контролируемом ответе API:

test('валидирует уникальный username', async () => {
  fetch.mockResolvedValueOnce({
    json: async () => ({ available: true })
  });

  const resolver = yupResolver(schema);

  const result = await resolver({
    username: 'unique_user'
  });

  expect(result.errors).toEqual({});
  expect(result.values.username).toBe('unique_user');
});

Ключевой момент — стабилизация внешнего состояния. Любая нестабильность моков приводит к флакiness тестов.


Тестирование ошибки асинхронной проверки

При отрицательном сценарии важно убедиться, что ошибка корректно попадает в структуру Yup:

test('возвращает ошибку при занятом username', async () => {
  fetch.mockResolvedValueOnce({
    json: async () => ({ available: false })
  });

  const resolver = yupResolver(schema);

  const result = await resolver({
    username: 'existing_user'
  });

  expect(result.errors.username).toBeDefined();
  expect(result.errors.username.message).toBe('Имя уже занято');
});

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


Использование fake timers при задержках

Если схема включает debounce или искусственные задержки через setTimeout, необходимо подключать управление временем.

jest.useFakeTimers();

Пример схемы с задержкой:

.test('async delay', async (value) => {
  await new Promise(res => setTimeout(res, 500));
  return value === 'ok';
});

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

test('обрабатывает задержку валидации', async () => {
  jest.useFakeTimers();

  const promise = resolver({ field: 'ok' });

  jest.advanceTimersByTime(500);

  const result = await promise;

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

Важно учитывать, что не все реализации Yup одинаково стабильно работают с fake timers, особенно при вложенных промисах.


Мокирование сервисного слоя вместо fetch

Более устойчивый подход заключается в выносе API-логики из схемы:

const checkUsername = async (name) => {
  const res = await api.get(`/check?name=${name}`);
  return res.available;
};

Схема:

.test('check-username', async (value) => {
  return await checkUsername(value);
});

Тест:

jest.mock('../api');

test('mock service layer', async () => {
  checkUsername.mockResolvedValue(true);

  const result = await resolver({ username: 'abc' });

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

Такой подход снижает связность тестов с HTTP-слоем и делает Yup-схему более предсказуемой.


Проверка race conditions в асинхронной валидации

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

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

test('игнорирует устаревшие ответы', async () => {
  let resolveFirst;
  let resolveSecond;

  fetch
    .mockImplementationOnce(() =>
      new Promise(res => { resolveFirst = res; })
    )
    .mockImplementationOnce(() =>
      new Promise(res => { resolveSecond = res; })
    );

  const resolver = yupResolver(schema);

  const p1 = resolver({ username: 'a' });
  const p2 = resolver({ username: 'ab' });

  resolveSecond({
    json: async () => ({ available: true })
  });

  resolveFirst({
    json: async () => ({ available: false })
  });

  const r2 = await p2;
  const r1 = await p1;

  expect(r2.values?.username).toBe('ab');
});

Такие сценарии выявляют проблемы, которые не видны при одиночных вызовах resolver.


Тестирование интеграции с react-hook-form

При использовании YupResolver в react-hook-form тестирование часто переносится на уровень пользовательского поведения.

const { result } = renderHook(() =>
  useForm({
    resolver: yupResolver(schema)
  })
);

Далее проверяется submit:

await act(async () => {
  await result.current.handleSubmit(() => {} )({
    username: 'test'
  });
});

Асинхронность здесь проявляется в задержке между submit и обновлением состояния ошибок.


Особенности работы с несколькими асинхронными правилами

Если схема содержит несколько .test() с промисами, Yup выполняет их последовательно или параллельно в зависимости от версии. Это влияет на стабильность тестов.

yup.object({
  field: yup
    .string()
    .test('t1', async () => true)
    .test('t2', async () => true)
})

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


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

Наиболее распространённые проблемы:

  • отсутствие await при вызове resolver
  • отсутствие моков внешних API
  • смешивание синхронных и асинхронных тестов без контроля таймеров
  • зависимость тестов друг от друга через глобальные моки
  • проверка структуры результата до завершения всех промисов

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


Асинхронная валидация в YupResolver требует выстраивания тестовой архитектуры вокруг Promise-модели выполнения. Основная сложность заключается не в самой Yup-схеме, а в синхронизации всех уровней: резолвера, моков, таймеров и UI-интеграции.