Тестирование кастомных валидаторов

Кастомные валидаторы в Yup часто становятся критическим элементом бизнес-логики, особенно когда стандартных правил недостаточно для описания доменной модели. Именно такие расширения требуют отдельного подхода к тестированию, поскольку их корректность напрямую влияет на валидацию форм, API-входов и внутренних структур данных.

В Yup кастомная логика реализуется через test, addMethod или композицию схем. Несмотря на различия в реализации, все такие валидаторы обладают общим свойством — они инкапсулируют произвольные правила, которые невозможно выразить стандартными методами (required, min, max, email и т.д.).

Типичный пример кастомного валидатора:

import * as Yup from 'yup';

const schema = Yup.string().test(
  'no-spaces',
  'Строка не должна содержать пробелы',
  (value) => !value || !value.includes(' ')
);

Подобная логика требует проверки не только «положительных» сценариев, но и всех пограничных состояний, включая null, undefined, пустые строки и неожиданные типы.

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

Тестирование Yup-схем строится вокруг метода validate или isValid. На практике чаще используется validate, так как он позволяет получить детализированную ошибку.

Пример теста с использованием Jest:

import * as Yup from 'yup';

const schema = Yup.string().test(
  'no-spaces',
  'no spaces allowed',
  (value) => !value || !value.includes(' ')
);

test('rejects strings with spaces', async () => {
  await expect(schema.validate('hello world')).rejects.toThrow('no spaces allowed');
});

test('accepts strings without spaces', async () => {
  await expect(schema.validate('helloworld')).resolves.toBe('helloworld');
});

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

Проверка пограничных значений

Кастомные валидаторы почти всегда содержат скрытые условия, которые проявляются только на edge cases. Основные категории таких значений:

  • undefined
  • null
  • пустая строка ''
  • строка из пробелов ' '
  • неожиданные типы (числа, объекты)
  • массивы вместо строк

Пример расширенного тестирования:

test('handles undefined', async () => {
  await expect(schema.validate(undefined)).resolves.toBeUndefined();
});

test('handles null', async () => {
  await expect(schema.validate(null)).resolves.toBeNull();
});

test('rejects whitespace-only string', async () => {
  await expect(schema.validate('   ')).rejects.toThrow();
});

Подобный набор проверок предотвращает ситуации, когда валидатор работает корректно только в рамках «идеальных» входных данных.

Тестирование с объектными схемами

На практике кастомные валидаторы чаще всего используются внутри Yup.object(). В этом случае важно тестировать не отдельное поле, а поведение всей схемы.

const schema = Yup.object({
  username: Yup.string().test(
    'no-admin',
    'invalid username',
    (value) => value !== 'admin'
  )
});

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

test('rejects forbidden username', async () => {
  await expect(schema.validate({ username: 'admin' }))
    .rejects.toThrow('invalid username');
});

test('accepts valid username', async () => {
  await expect(schema.validate({ username: 'user1' }))
    .resolves.toMatchObject({ username: 'user1' });
});

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

Асинхронные кастомные валидаторы

Yup поддерживает асинхронные проверки, например запросы к API или базе данных:

const schema = Yup.string().test(
  'is-unique',
  'already exists',
  async (value) => {
    const exists = await fakeApiCheck(value);
    return !exists;
  }
);

Тестирование таких валидаторов требует мокирования внешних зависимостей:

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

import { fakeApiCheck } from './api';

test('rejects existing value', async () => {
  fakeApiCheck.mockResolvedValue(true);

  await expect(schema.validate('test')).rejects.toThrow('already exists');
});

test('accepts unique value', async () => {
  fakeApiCheck.mockResolvedValue(false);

  await expect(schema.validate('test')).resolves.toBe('test');
});

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

Использование параметризованных тестов

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

const cases = [
  ['admin', false],
  ['root', false],
  ['user', true],
  ['guest', true]
];

test.each(cases)('validates username %s', async (input, expected) => {
  if (expected) {
    await expect(schema.validate(input)).resolves.toBe(input);
  } else {
    await expect(schema.validate(input)).rejects.toThrow();
  }
});

Такой подход уменьшает дублирование и повышает читаемость тестов.

Проверка поведения при преобразованиях (transform)

Yup позволяет использовать transform, который изменяет входные данные до валидации. Это создаёт дополнительный слой, который обязательно должен быть протестирован.

const schema = Yup.number().transform((value, originalValue) => {
  return typeof originalValue === 'string'
    ? parseInt(originalValue, 10)
    : value;
});

Тест:

test('transforms string to number', async () => {
  await expect(schema.validate('10')).resolves.toBe(10);
});

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

Ошибки, возникающие при тестировании кастомных валидаторов

Игнорирование async/await

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

Проверка только «успешных» сценариев

Валидация без негативных кейсов не отражает реального поведения системы.

Отсутствие контроля моков

Любая внешняя зависимость должна быть изолирована, иначе тесты становятся нестабильными.

Проверка схемы вместо логики

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

Структурирование тестов в проекте

При росте количества кастомных валидаторов становится важна организация тестов:

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

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

validation/
  schemas/
    user.schema.js
  __tests__/
    user.schema.test.js
    validators/
      noSpaces.test.js
      uniqueUsername.test.js

Такой подход снижает связанность и упрощает сопровождение.

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

В Yup часто комбинируются несколько кастомных правил:

Yup.string()
  .test('no-spaces', ...)
  .test('no-special-chars', ...)
  .test('not-admin', ...)

Тестирование должно учитывать порядок выполнения и потенциальные конфликты:

test('fails on first invalid rule', async () => {
  await expect(schema.validate('admin user')).rejects.toThrow();
});

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

Практические паттерны тестирования

Наиболее устойчивые подходы к тестированию кастомных валидаторов включают:

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

Особую роль играет повторяемость: тесты должны оставаться детерминированными независимо от среды выполнения.

Кастомные валидаторы в Yup становятся надёжным элементом системы только тогда, когда их поведение полностью описано тестами, охватывающими как типичные, так и нетривиальные сценарии использования.