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

Асинхронная валидация используется в случаях, когда проверка данных требует обращения к внешнему источнику:

  • HTTP API;
  • базе данных;
  • серверу авторизации;
  • удалённым сервисам;
  • файловой системе;
  • кэшам и очередям.

Типичные сценарии:

  • проверка уникальности email;
  • существование пользователя;
  • валидность промокода;
  • доступность имени аккаунта;
  • проверка CAPTCHA;
  • верификация токенов;
  • загрузка и анализ файлов.

В библиотеке Vest асинхронная валидация встроена в архитектуру suites и тесно связана с механизмами:

  • test
  • enforce
  • async
  • warn
  • skipWhen
  • only
  • group
  • optional
  • состоянием выполнения suite

Базовая асинхронная проверка

Простейшая асинхронная проверка строится вокруг async и await.

import vest, { test, enforce } from 'vest';

const suite = vest.create(async (data) => {
  test('email', 'Email уже используется', async () => {
    const response = await fetch(`/api/check-email?email=${data.email}`);
    const result = await response.json();

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

Особенности:

  • callback внутри test становится асинхронным;
  • Vest ожидает завершения Promise;
  • ошибка появится только после завершения запроса;
  • suite автоматически отслеживает состояние выполнения.

Асинхронная suite

Весь suite также может быть асинхронным.

const suite = vest.create(async (data) => {
  await preloadConfiguration();

  test('username', async () => {
    const result = await checkUsername(data.username);

    enforce(result.valid).isTruthy();
  });
});

Это полезно, если:

  • необходимо предварительно загрузить конфигурацию;
  • требуется авторизация;
  • используются shared resources;
  • нужен предварительный запрос перед всеми тестами.

Проверка уникальности email

Один из наиболее распространённых сценариев.

import vest, { test, enforce } from 'vest';

async function isEmailAvailable(email) {
  const response = await fetch('/api/email/check', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ email })
  });

  return response.json();
}

const suite = vest.create((data) => {
  test('email', 'Email уже зарегистрирован', async () => {
    const result = await isEmailAvailable(data.email);

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

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

Vest предоставляет API для анализа состояния валидации.

const result = suite(formData);

result.isPending();
result.hasErrors();
result.hasWarnings();

Проверка pending-состояния:

if (result.isPending()) {
  console.log('Проверка выполняется');
}

Проверка конкретного поля:

result.isPending('email');

Это особенно важно для UI:

  • отображение spinner;
  • блокировка submit;
  • индикаторы загрузки;
  • disabled-состояния кнопок.

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

Vest способен выполнять несколько асинхронных тестов параллельно.

const suite = vest.create((data) => {
  test('email', async () => {
    const result = await checkEmail(data.email);

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

  test('username', async () => {
    const result = await checkUsername(data.username);

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

  test('promoCode', async () => {
    const result = await validatePromo(data.promoCode);

    enforce(result.valid).isTruthy();
  });
});

Все проверки запускаются независимо.

Преимущества:

  • минимизация общего времени валидации;
  • отсутствие блокировок между тестами;
  • естественная параллельность Promise.

Последовательная асинхронная валидация

Иногда проверки должны выполняться последовательно.

Например:

  1. проверить формат email;
  2. только затем обращаться к API.
const suite = vest.create((data) => {
  test('email', 'Некорректный email', () => {
    enforce(data.email).matches(/^\S+@\S+\.\S+$/);
  });

  test('email', 'Email уже существует', async () => {
    const result = await checkEmail(data.email);

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

Однако такой вариант всё ещё может выполнять второй тест.

Для предотвращения лишнего запроса применяется skipWhen.


skipWhen и асинхронные проверки

import vest, {
  create,
  test,
  skipWhen,
  enforce
} from 'vest';

const suite = create((data) => {
  test('email', 'Неверный email', () => {
    enforce(data.email).matches(/^\S+@\S+\.\S+$/);
  });

  skipWhen(
    res => res.hasErrors('email'),
    () => {
      test('email', 'Email занят', async () => {
        const result = await checkEmail(data.email);

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

Теперь API-запрос выполняется только при успешной синхронной проверке.


Валидация username с debounce

Асинхронные проверки часто вызываются при вводе текста.

Без debounce возникают проблемы:

  • большое количество запросов;
  • race conditions;
  • перегрузка API;
  • мерцание ошибок.

Пример debounce:

import debounce from 'lodash.debounce';

const validateUsername = debounce(async (value) => {
  return await checkUsername(value);
}, 300);

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

test('username', async () => {
  const result = await validateUsername(data.username);

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

Race conditions

Одна из ключевых проблем асинхронной валидации — устаревшие ответы.

Сценарий:

  1. пользователь вводит alex;
  2. отправляется запрос;
  3. пользователь вводит alexander;
  4. второй запрос завершается раньше;
  5. первый запрос возвращает устаревшую ошибку.

Vest помогает управлять подобными ситуациями благодаря stateful suites.


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

Кэширование уменьшает количество запросов.

const cache = new Map();

async function checkEmailCached(email) {
  if (cache.has(email)) {
    return cache.get(email);
  }

  const result = await checkEmail(email);

  cache.set(email, result);

  return result;
}

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

test('email', async () => {
  const result = await checkEmailCached(data.email);

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

Асинхронные предупреждения

Не каждая проблема должна быть ошибкой.

Пример:

  • слабый пароль;
  • временный email;
  • подозрительный username;
  • устаревший домен.
import { warn } from 'vest';

test('password', 'Слишком простой пароль', async () => {
  warn();

  const result = await analyzePassword(data.password);

  enforce(result.strong).isTruthy();
});

Теперь сообщение будет warning, а не error.


Асинхронные группы

Группы позволяют логически объединять проверки.

import { group } from 'vest';

group('account', () => {
  test('email', async () => {
    const result = await checkEmail(data.email);

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

  test('username', async () => {
    const result = await checkUsername(data.username);

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

Получение ошибок группы:

result.getErrorsByGroup('account');

optional и асинхронные проверки

Иногда поле необязательно.

import { optional } from 'vest';

optional('promoCode');

test('promoCode', async () => {
  const result = await validatePromo(data.promoCode);

  enforce(result.valid).isTruthy();
});

Если поле пустое, запрос не выполняется.


only и частичная асинхронная валидация

При больших формах нет смысла валидировать всё.

suite(data, 'email');

Теперь выполняются только тесты поля email.

Это критически важно для:

  • realtime validation;
  • оптимизации API;
  • производительности;
  • уменьшения нагрузки.

Динамическая асинхронная валидация

Проверки могут зависеть от данных формы.

const suite = vest.create((data) => {
  if (data.accountType === 'business') {
    test('taxId', async () => {
      const result = await verifyTaxId(data.taxId);

      enforce(result.valid).isTruthy();
    });
  }
});

Обработка ошибок сети

Ошибка API не всегда означает ошибку поля.

Плохой подход:

test('email', async () => {
  const result = await checkEmail(data.email);

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

Если сервер недоступен — возникнет unhandled rejection.

Корректный вариант:

test('email', 'Ошибка проверки email', async () => {
  try {
    const result = await checkEmail(data.email);

    enforce(result.available).isTruthy();
  } catch (e) {
    enforce.fail();
  }
});

Разделение бизнес-ошибок и технических ошибок

Полезно различать:

  • validation errors;
  • network errors;
  • server errors;
  • authorization errors.
test('email', async () => {
  try {
    const result = await checkEmail(data.email);

    if (result.error === 'RATE_LIMIT') {
      throw new Error('Too many requests');
    }

    enforce(result.available).isTruthy();
  } catch (e) {
    console.error(e);
  }
});

AbortController и отмена запросов

Современная асинхронная валидация часто требует отмены предыдущих запросов.

let controller;

async function validateEmail(email) {
  if (controller) {
    controller.abort();
  }

  controller = new AbortController();

  const response = await fetch('/api/check-email', {
    method: 'POST',
    signal: controller.signal,
    body: JSON.stringify({ email })
  });

  return response.json();
}

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

test('email', async () => {
  const result = await validateEmail(data.email);

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

Асинхронная проверка файлов

Пример проверки изображения.

test('avatar', 'Файл слишком большой', async () => {
  const metadata = await getImageMetadata(data.avatar);

  enforce(metadata.size).lessThan(5 * 1024 * 1024);
});

Проверка размеров изображения:

test('avatar', 'Недопустимое разрешение', async () => {
  const metadata = await getImageMetadata(data.avatar);

  enforce(metadata.width).greaterThan(300);
  enforce(metadata.height).greaterThan(300);
});

Комплексная асинхронная форма регистрации

import vest, {
  test,
  enforce,
  skipWhen,
  optional,
  group
} from 'vest';

const suite = vest.create((data) => {
  group('account', () => {
    test('email', 'Некорректный email', () => {
      enforce(data.email).matches(/^\S+@\S+\.\S+$/);
    });

    skipWhen(
      res => res.hasErrors('email'),
      () => {
        test('email', 'Email уже используется', async () => {
          const result = await checkEmail(data.email);

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

    test('username', 'Минимум 4 символа', () => {
      enforce(data.username).longerThanOrEquals(4);
    });

    skipWhen(
      res => res.hasErrors('username'),
      () => {
        test('username', 'Username занят', async () => {
          const result = await checkUsername(data.username);

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

  test('password', 'Минимум 8 символов', () => {
    enforce(data.password).longerThanOrEquals(8);
  });

  test('password', 'Пароль слишком простой', async () => {
    const result = await analyzePassword(data.password);

    enforce(result.score).greaterThan(60);
  });

  optional('promoCode');

  test('promoCode', 'Промокод недействителен', async () => {
    const result = await validatePromo(data.promoCode);

    enforce(result.valid).isTruthy();
  });
});

Архитектура асинхронной валидации

Хорошая архитектура обычно разделяет:

1. UI

Отображение ошибок и loading-state.

2. Validation Layer

Логика Vest suites.

3. Service Layer

HTTP-клиенты и API.

4. Data Layer

Кэширование, memoization, retry.


Практики производительности

Минимизировать количество запросов

Используются:

  • debounce;
  • throttle;
  • caching;
  • partial validation;
  • lazy validation.

Проверять синхронные условия раньше

Сначала:

enforce(data.email).matches(...)

Только потом:

await checkEmail(...)

Использовать partial validation

suite(data, 'email');

Вместо:

suite(data);

Избегать тяжёлых async-тестов

Плохо:

test('field', async () => {
  await hugeOperation();
});

Лучше:

  • выносить вычисления;
  • использовать фоновые сервисы;
  • кэшировать результаты.

Типичные ошибки

Выполнение async-запросов без необходимости

Плохо:

test('email', async () => {
  await checkEmail(data.email);
});

Лучше:

skipWhen(
  res => res.hasErrors('email'),
  () => {
    test('email', async () => {
      await checkEmail(data.email);
    });
  }
);

Игнорирование pending-state

Без isPending() интерфейс может:

  • отправить форму раньше времени;
  • показать устаревшие ошибки;
  • мерцать при обновлении.

Отсутствие отмены запросов

Без AbortController возникают:

  • race conditions;
  • утечки памяти;
  • неправильные ошибки.

Смешивание network errors и validation errors

Validation:

Email уже существует

Network:

Сервер недоступен

Это разные категории проблем.


Интеграция с React

Асинхронная валидация особенно востребована в React-приложениях.

Пример:

const result = suite(formData);

const emailPending = result.isPending('email');
const emailErrors = result.getErrors('email');

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

{emailPending && <Spinner />}

{emailErrors.map(error => (
  <div key={error}>{error}</div>
))}

Интеграция с form libraries

Vest часто используется вместе с:

  • React Hook Form
  • Formik
  • Final Form

Асинхронная валидация обычно подключается как внешний validation engine.


Масштабирование больших validation suites

При росте приложения полезно:

  • разбивать suite на модули;
  • выделять async-services;
  • централизовать API validation;
  • переиспользовать validators;
  • вводить shared caching layer.

Пример структуры:

validation/
├── suites/
├── async/
├── services/
├── cache/
├── validators/
└── utils/

Переиспользуемые async validators

export async function validateEmailAvailability(email) {
  const result = await checkEmail(email);

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

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

test('email', async () => {
  await validateEmailAvailability(data.email);
});

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

Наиболее эффективная стратегия:

  1. быстрые sync-проверки;
  2. conditional execution;
  3. async API validation;
  4. warnings и secondary checks.

Такая схема обеспечивает:

  • минимальное количество запросов;
  • быстрый feedback;
  • устойчивость интерфейса;
  • высокую производительность;
  • предсказуемое поведение формы.