Композиция правил

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

Подход особенно важен в крупных приложениях, где:

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

Базовая структура композиции

Типичный suite в Vest выглядит следующим образом:

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

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

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

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


Выделение правил в отдельные функции

Простая декомпозиция

Вместо размещения всех тестов внутри create правила выносятся в независимые функции.

function validateEmail(data) {
  test('email', 'Некорректный email', () => {
    enforce(data.email).matches(/^\S+@\S+\.\S+$/);
  });
}

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

const suite = create((data = {}) => {
  validateEmail(data);
  validatePassword(data);
});

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

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

Композиция через группы правил

Группировка по доменам

Крупные формы удобно делить по смысловым областям.

function validateAccount(data) {
  validateEmail(data);
  validatePassword(data);
}

function validateProfile(data) {
  validateFirstName(data);
  validateLastName(data);
}

const suite = create((data = {}) => {
  validateAccount(data);
  validateProfile(data);
});

Такой подход особенно полезен при работе с:

  • регистрацией;
  • многошаговыми формами;
  • административными панелями;
  • CRM-системами;
  • конструкторами анкет.

Переиспользуемые валидаторы

Универсальные правила

Часто одинаковые ограничения применяются к разным полям.

Например, проверка обязательности:

function required(fieldName, value) {
  test(fieldName, 'Поле обязательно', () => {
    enforce(value).isNotBlank();
  });
}

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

const suite = create((data = {}) => {
  required('email', data.email);
  required('password', data.password);
});

Композиция нескольких проверок

Можно создавать полноценные наборы правил.

function validateEmailField(fieldName, value) {
  required(fieldName, value);

  test(fieldName, 'Некорректный email', () => {
    enforce(value).matches(/^\S+@\S+\.\S+$/);
  });
}

Теперь логика повторно используется в любом suite.


Композиция через массивы правил

Динамическое применение валидаторов

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

const rules = [
  validateEmail,
  validatePassword,
  validateProfile
];

const suite = create((data = {}) => {
  rules.forEach(rule => rule(data));
});

Подобный подход полезен при:

  • подключении модулей;
  • расширяемой архитектуре;
  • plugin-based системах;
  • динамических формах.

Условная композиция

Подключение правил по условию

Некоторые проверки должны выполняться только при определённых обстоятельствах.

const suite = create((data = {}) => {
  validateEmail(data);

  if (data.hasPassword) {
    validatePassword(data);
  }
});

Композиция по ролям пользователя

function validateAdmin(data) {
  test('role', 'Недостаточно прав', () => {
    enforce(data.isAdmin).isTruthy();
  });
}

const suite = create((data = {}) => {
  validateProfile(data);

  if (data.role === 'admin') {
    validateAdmin(data);
  }
});

Композиция многошаговых форм

Разделение по шагам

function stepOne(data) {
  validateEmail(data);
}

function stepTwo(data) {
  validatePassword(data);
}

function stepThree(data) {
  validateProfile(data);
}

Активация текущего шага

const suite = create((data = {}, currentStep) => {
  if (currentStep === 1) {
    stepOne(data);
  }

  if (currentStep === 2) {
    stepTwo(data);
  }

  if (currentStep === 3) {
    stepThree(data);
  }
});

Такой подход:

  • уменьшает количество ошибок;
  • ускоряет выполнение;
  • упрощает UX;
  • позволяет валидировать форму постепенно.

Композиция с only

Ограничение области проверки

only позволяет запускать только определённые тесты.

import { create, test, only } from 'vest';

const suite = create((data = {}, fieldName) => {
  only(fieldName);

  validateEmail(data);
  validatePassword(data);
});

Комбинация с композиционными блоками

function validateSecurity(data) {
  validatePassword(data);
  validateTwoFactor(data);
}
const suite = create((data = {}, fieldName) => {
  only(fieldName);

  validateSecurity(data);
});

Даже при сложной композиции Vest выполнит только нужные тесты.


Композиция с skip

Исключение ненужных правил

import { skip } from 'vest';

const suite = create((data = {}) => {
  skip(!data.shouldValidatePassword, () => {
    validatePassword(data);
  });
});

Временное отключение группы правил

skip(data.isGuest, () => {
  validateProfile(data);
  validateSecurity(data);
});

Композиция асинхронных правил

Смешивание sync и async валидаторов

function validateUsername(data) {
  test('username', 'Имя слишком короткое', () => {
    enforce(data.username).longerThan(3);
  });

  test.memo(
    'username',
    'Пользователь уже существует',
    async () => {
      const exists = await api.userExists(data.username);

      enforce(exists).isFalsy();
    }
  );
}

Инкапсуляция асинхронной логики

function validateUniqueEmail(data) {
  test.memo(
    'email',
    'Email уже используется',
    async () => {
      const exists = await api.emailExists(data.email);

      enforce(exists).isFalsy();
    }
  );
}

Композиция скрывает детали реализации и упрощает основной suite.


Композиция через фабрики валидаторов

Генерация правил

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

function createMinLengthValidator(field, min) {
  return function(data) {
    test(field, `Минимум ${min} символов`, () => {
      enforce(data[field]).longerThanOrEquals(min);
    });
  };
}

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

const validateUsername =
  createMinLengthValidator('username', 4);

const validatePassword =
  createMinLengthValidator('password', 8);

Композиция с конфигурацией

Конфигурируемые валидаторы

function createRequiredValidator(message) {
  return function(field, value) {
    test(field, message, () => {
      enforce(value).isNotBlank();
    });
  };
}

Локализация правил

const requiredRu =
  createRequiredValidator('Поле обязательно');

const requiredEn =
  createRequiredValidator('Field is required');

Композиция через middleware-подход

Последовательная обработка

const validators = [
  validateEmail,
  validatePassword,
  validateProfile
];

const suite = create((data = {}) => {
  validators.reduce((_, validator) => {
    validator(data);
  }, null);
});

Хотя reduce здесь не обязателен, подход показывает pipeline-архитектуру.


Наследование правил

Расширение базовой валидации

function baseUserValidation(data) {
  validateEmail(data);
  validatePassword(data);
}
function adminValidation(data) {
  baseUserValidation(data);

  validateAdminPermissions(data);
}

Многоуровневая композиция

function employeeValidation(data) {
  baseUserValidation(data);

  validateDepartment(data);
  validatePosition(data);
}

Такой подход напоминает наследование классов, но без жёсткой связности.


Композиция вложенных объектов

Валидация сложных структур

function validateAddress(address) {
  test('city', 'Укажите город', () => {
    enforce(address.city).isNotBlank();
  });

  test('street', 'Укажите улицу', () => {
    enforce(address.street).isNotBlank();
  });
}

Использование внутри suite

const suite = create((data = {}) => {
  validateAddress(data.address);
});

Композиция массивов

Проверка элементов коллекции

function validateTag(tag, index) {
  test(`tags[${index}]`, 'Тег слишком короткий', () => {
    enforce(tag).longerThan(2);
  });
}

Массовое применение

const suite = create((data = {}) => {
  data.tags.forEach((tag, index) => {
    validateTag(tag, index);
  });
});

Композиция и разделение ответственности

Антипаттерн: монолитный suite

const suite = create((data = {}) => {
  // сотни строк тестов
});

Проблемы:

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

Правильная композиция

const suite = create((data = {}) => {
  validateAccount(data);
  validateProfile(data);
  validatePermissions(data);
  validateNotifications(data);
});

Каждый блок отвечает только за собственную область.


Композиция и тестируемость

Изолированное тестирование правил

function validateAge(data) {
  test('age', 'Возраст должен быть больше 18', () => {
    enforce(data.age).greaterThan(18);
  });
}

Подобные функции значительно проще покрывать unit-тестами.


Архитектура папок

Типичная структура проекта

validation/
├── rules/
│   ├── email.js
│   ├── password.js
│   └── profile.js
│
├── groups/
│   ├── account.js
│   └── security.js
│
├── suites/
│   ├── registrationSuite.js
│   └── loginSuite.js

Композиция и масштабирование

При росте приложения композиция становится критически важной.

Без композиции:

  • suite превращаются в огромные файлы;
  • правила дублируются;
  • усложняется поддержка;
  • повышается вероятность регрессий.

С композицией:

  • правила переиспользуются;
  • код становится модульным;
  • проще внедрять новые сценарии;
  • уменьшается стоимость изменений.

Практический пример полной композиции

import { create } from 'vest';

function validateEmail(data) {
  required('email', data.email);

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

function validatePassword(data) {
  required('password', data.password);

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

function validateProfile(data) {
  required('firstName', data.firstName);
  required('lastName', data.lastName);
}

function validateAdmin(data) {
  test('accessLevel', 'Недостаточный уровень доступа', () => {
    enforce(data.accessLevel).greaterThan(5);
  });
}

export const suite = create((data = {}) => {
  validateEmail(data);
  validatePassword(data);
  validateProfile(data);

  if (data.role === 'admin') {
    validateAdmin(data);
  }
});

В результате формируется:

  • модульная структура;
  • независимая логика;
  • масштабируемая архитектура;
  • высокая читаемость;
  • переиспользуемость правил;
  • упрощённая поддержка кода.