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

Асинхронные правила валидации в схемах Vest применяются там, где проверка зависит от внешних источников данных: сетевых запросов, обращений к API, базы данных или любых операций с задержкой. В отличие от синхронных проверок, такие правила возвращают результат не сразу, а через Promise, что требует отдельного подхода как при реализации, так и при тестировании.

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


Модель асинхронного правила в Vest

Асинхронное правило в Vest представляет собой функцию, возвращающую Promise. Внутри таких правил обычно выполняются запросы к внешним сервисам или имитации этих запросов в тестовой среде.

Типовая структура:

import { test, enforce } from 'vest';

test('email', 'EMAIL_ALREADY_USED', async () => {
  const res = await fetch(`/api/check-email?value=${value}`);
  const data = await res.json();

  enforce(data.available).isTruthy();
});

Особенность выполнения заключается в том, что Vest не блокирует выполнение всей схемы, а параллелит асинхронные проверки, сохраняя общий поток выполнения валидации.


Поведение асинхронных правил в тестовой среде

Тестирование асинхронных правил требует учета нескольких факторов:

  1. Порядок разрешения Promise не гарантирован.
  2. Результат схемы может быть промежуточным до завершения всех проверок.
  3. Необходимо дождаться завершения всей валидации, а не отдельных правил.
  4. Внешние зависимости должны быть изолированы.

Основная ошибка при тестировании — проверка состояния до завершения всех асинхронных операций.


Базовый подход к тестированию схем с async-правилами

Наиболее прямой способ — ожидание результата выполнения схемы.

import { suite } from 'vest';

const userSuite = (data) =>
  suite('user', () => {
    test('username', 'USERNAME_TAKEN', async () => {
      const res = await fetch(`/api/username?u=${data.username}`);
      const json = await res.json();

      enforce(json.available).isTruthy();
    });
  });

Тест:

it('проверяет доступность username', async () => {
  fetch.mockResponseOnce(JSON.stringify({ available: false }));

  const result = await userSuite({ username: 'john' });

  expect(result.hasErrors('username')).toBe(true);
});

Ключевой момент — тест сам должен быть асинхронным и дожидаться результата выполнения suite.


Изоляция внешних запросов

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

Мокирование fetch

global.fetch = jest.fn();

Или более точечно:

fetch.mockImplementation(() =>
  Promise.resolve({
    json: () => Promise.resolve({ available: true }),
  })
);

Такая изоляция позволяет полностью контролировать поведение асинхронного правила без обращения к реальному API.


Тестирование успешного сценария

Асинхронные правила должны корректно подтверждать валидные данные.

it('не возвращает ошибку, если username доступен', async () => {
  fetch.mockResolvedValue({
    json: () => Promise.resolve({ available: true }),
  });

  const result = await userSuite({ username: 'valid_user' });

  expect(result.hasErrors('username')).toBe(false);
});

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


Тестирование негативного сценария

При отрицательном ответе API схема должна фиксировать ошибку.

it('возвращает ошибку, если username занят', async () => {
  fetch.mockResolvedValue({
    json: () => Promise.resolve({ available: false }),
  });

  const result = await userSuite({ username: 'taken_user' });

  expect(result.hasErrors('username')).toBe(true);
});

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


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

Асинхронные правила могут запускаться параллельно. Это создаёт риск гонок состояний при тестировании.

Типичная ситуация:

  • два запроса отправлены почти одновременно
  • второй завершился раньше первого
  • результат первого перезаписал состояние

Для тестирования таких сценариев используется контроль порядка резолва Promise:

fetch
  .mockImplementationOnce(() =>
    new Promise((resolve) =>
      setTimeout(
        () => resolve({ json: () => Promise.resolve({ available: true }) }),
        50
      )
    )
  )
  .mockImplementationOnce(() =>
    Promise.resolve({
      json: () => Promise.resolve({ available: false }),
    })
  );

Проверка:

it('обрабатывает конкурирующие запросы корректно', async () => {
  const result = await userSuite({ username: 'race_user' });

  expect(result.hasErrors('username')).toBe(true);
});

Использование fake timers

При тестировании задержек удобно использовать fake timers, особенно если внутри правил присутствует debounce или искусственные задержки.

jest.useFakeTimers();

Асинхронная логика:

test('username', async () => {
  await new Promise((resolve) => setTimeout(resolve, 300));
  enforce(false).equals(true);
});

Тест:

it('корректно отрабатывает задержку', async () => {
  const promise = userSuite({ username: 'delayed' });

  jest.advanceTimersByTime(300);

  const result = await promise;

  expect(result.hasErrors('username')).toBe(true);
});

Комбинация async и sync правил

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

test('username', () => {
  enforce(value).isNotEmpty();

  return fetch(`/api/check?u=${value}`)
    .then((r) => r.json())
    .then((data) => {
      enforce(data.available).isTruthy();
    });
});

В таких случаях тестирование всегда строится вокруг финального результата suite, а не отдельных правил.


Проверка промежуточного состояния

Иногда требуется протестировать состояние до завершения всех async операций. Это возможно только через контроль выполнения Promise и частичное ожидание.

const promise = userSuite({ username: 'pending' });

const partial = userSuite.get(); // промежуточное состояние

expect(partial.isValid('username')).toBeUndefined();

const result = await promise;

expect(result.hasErrors('username')).toBe(true);

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


Моки модулей API-слоя

При сложной архитектуре fetch обычно выносится в отдельный слой:

import api from '../api/user';

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

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

Использование в схеме:

test('username', async () => {
  const res = await api.checkUsername(value);
  enforce(res.available).isTruthy();
});

Такой подход упрощает тестирование, устраняя зависимость от глобального fetch.


Ошибки, возникающие при тестировании async правил

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

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

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


Поведение ошибок внутри асинхронных правил

Если асинхронное правило выбрасывает исключение, Vest фиксирует его как ошибку поля.

test('email', async () => {
  throw new Error('network failure');
});

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

it('обрабатывает ошибки сети', async () => {
  fetch.mockRejectedValue(new Error('network'));

  const result = await userSuite({ email: 'a@b.com' });

  expect(result.hasErrors('email')).toBe(true);
});

Проверка множественных async правил в одной схеме

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

test('email', async () => { /* check 1 */ });
test('email', async () => { /* check 2 */ });
test('email', async () => { /* check 3 */ });

Результат объединяется, и тестирование всегда ориентируется на финальный агрегированный state:

const result = await userSuite({ email: 'test@mail.com' });

expect(result.hasErrors('email')).toBe(true);