Композиция правил в Vest — это подход к организации
валидации, при котором отдельные проверки объединяются в независимые,
переиспользуемые и управляемые блоки. Вместо монолитного набора условий
формируется система декларативных правил, которые можно комбинировать,
отключать, расширять и переиспользовать между формами.
Подход особенно важен в крупных приложениях, где:
Типичный 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);
});
Такой подход особенно полезен при работе с:
Часто одинаковые ограничения применяются к разным полям.
Например, проверка обязательности:
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));
});
Подобный подход полезен при:
Некоторые проверки должны выполняться только при определённых обстоятельствах.
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);
}
});
Такой подход:
onlyonly позволяет запускать только определённые тесты.
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 выполнит только нужные
тесты.
skipimport { skip } from 'vest';
const suite = create((data = {}) => {
skip(!data.shouldValidatePassword, () => {
validatePassword(data);
});
});
skip(data.isGuest, () => {
validateProfile(data);
validateSecurity(data);
});
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');
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();
});
}
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);
});
});
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
При росте приложения композиция становится критически важной.
Без композиции:
С композицией:
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);
}
});
В результате формируется: