Массивы в формах встречаются постоянно: списки тегов, телефонов, адресов, товаров, вариантов ответов, файлов, ролей пользователей. Валидация таких структур отличается от проверки обычных полей тем, что необходимо учитывать:
Библиотека Vest предоставляет механизмы для организации подобных проверок через suites, группировку тестов, избирательную валидацию и работу с динамическими данными.
Наиболее распространённая задача — ограничение минимального и максимального количества элементов.
import vest, { test, enforce } from 'vest';
const suite = vest.create('tags_form', (data = {}) => {
test('tags', 'Добавьте хотя бы один тег', () => {
enforce(data.tags).isArray();
enforce(data.tags.length).greaterThan(0);
});
});
Проверка выполняется в два этапа:
test('tags', 'Допустимо не более 10 тегов', () => {
enforce(data.tags.length).lessThanOrEquals(10);
});
test('skills', 'Количество навыков должно быть от 3 до 8', () => {
enforce(data.skills.length)
.greaterThanOrEquals(3);
enforce(data.skills.length)
.lessThanOrEquals(8);
});
Во многих случаях массив существует, но содержит пустые элементы.
test('emails', 'Список содержит пустые email', () => {
data.emails.forEach(email => {
enforce(email.trim()).isNotBlank();
});
});
test('tags', 'Тег не может состоять только из пробелов', () => {
data.tags.forEach(tag => {
enforce(tag.trim()).longerThan(0);
});
});
Массивы часто содержат объекты.
const data = {
users: [
{
name: 'Alex',
age: 25
}
]
};
Проверка:
test('users', 'Некорректный пользователь', () => {
data.users.forEach(user => {
enforce(user.name).isNotBlank();
enforce(user.age).greaterThan(17);
});
});
test('addresses', 'Заполните адрес полностью', () => {
data.addresses.forEach(address => {
enforce(address.city).isNotBlank();
enforce(address.street).isNotBlank();
enforce(address.zip).matches(/^\d{6}$/);
});
});
При работе с динамическими формами важно понимать, какой именно элемент массива содержит ошибку.
data.users.forEach((user, index) => {
test(`users[${index}].email`, 'Некорректный email', () => {
enforce(user.email).matches(/^\S+@\S+\.\S+$/);
});
});
Теперь ошибки будут храниться отдельно:
users[0].email
users[1].email
users[2].email
Это особенно важно для интерфейсов с повторяющимися блоками формы.
test('emails', 'Email должны быть уникальными', () => {
const unique = new Set(data.emails);
enforce(unique.size).equals(data.emails.length);
});
Например, массив товаров:
[
{ id: 1, title: 'Phone' },
{ id: 2, title: 'Laptop' }
]
Проверка:
test('products', 'Товары дублируются', () => {
const ids = data.products.map(product => product.id);
const unique = new Set(ids);
enforce(unique.size).equals(ids.length);
});
test('roles', 'Нужен хотя бы один администратор', () => {
const hasAdmin = data.roles.some(
role => role === 'admin'
);
enforce(hasAdmin).isTruthy();
});
test('prices', 'Цена должна быть положительной', () => {
const valid = data.prices.every(
price => price > 0
);
enforce(valid).isTruthy();
});
Формы загрузки файлов обычно используют массивы.
test('files', 'Можно загрузить максимум 5 файлов', () => {
enforce(data.files.length)
.lessThanOrEquals(5);
});
test('files', 'Файл слишком большой', () => {
data.files.forEach(file => {
enforce(file.size)
.lessThanOrEquals(5 * 1024 * 1024);
});
});
test('files', 'Недопустимый тип файла', () => {
const allowed = [
'image/png',
'image/jpeg'
];
data.files.forEach(file => {
enforce(allowed.includes(file.type))
.isTruthy();
});
});
Иногда массив содержит другие массивы.
const data = {
matrix: [
[1, 2],
[3, 4]
]
};
test('matrix', 'Матрица содержит отрицательные числа', () => {
data.matrix.forEach(row => {
row.forEach(cell => {
enforce(cell).greaterThan(0);
});
});
});
const data = {
items: [
{
price: 100,
quantity: 2
}
]
};
test('items', 'Количество должно быть больше нуля', () => {
data.items.forEach(item => {
enforce(item.quantity).greaterThan(0);
});
});
test('items', 'Цена должна быть положительной', () => {
data.items.forEach(item => {
enforce(item.price).greaterThan(0);
});
});
test('permissions', 'Конфликт ролей', () => {
const hasGuest = data.permissions.includes('guest');
const hasAdmin = data.permissions.includes('admin');
enforce(hasGuest && hasAdmin).isFalsy();
});
Проверки могут зависеть от других полей формы.
test('phones', 'Добавьте телефон', () => {
if (!data.enablePhones) {
return;
}
enforce(data.phones.length)
.greaterThan(0);
});
Vest позволяет запускать проверку только изменённой части формы.
suite(data, `users[2].email`);
Будет выполнена только проверка:
test('users[2].email', ...)
Это существенно уменьшает нагрузку при больших массивах.
Массивы часто становятся необязательными.
import { skipWhen } from 'vest';
skipWhen(
data.tags.length === 0,
() => {
test('tags', 'Слишком много тегов', () => {
enforce(data.tags.length)
.lessThanOrEquals(10);
});
}
);
Например, проверка списка логинов на сервере.
test.memo(
'users',
'Логин уже существует',
async () => {
for (const user of data.users) {
const response = await fetch(
`/api/users/${user.login}`
);
const exists = await response.json();
enforce(exists).isFalsy();
}
}
);
При работе с большими коллекциями важно избегать повторных вычислений.
data.items.forEach(item => {
const duplicate = data.items.find(
current => current.id === item.id
);
enforce(duplicate).isDefined();
});
Здесь выполняется вложенный поиск.
test('items', 'Найдены дубликаты', () => {
const ids = new Set();
data.items.forEach(item => {
enforce(ids.has(item.id)).isFalsy();
ids.add(item.id);
});
});
Сложность уменьшается с O(n²) до O(n).
import { group } from 'vest';
group('users', () => {
data.users.forEach((user, index) => {
test(
`users[${index}].name`,
'Введите имя',
() => {
enforce(user.name).isNotBlank();
}
);
});
});
Группы помогают:
При удалении элементов индексы меняются.
users[0]
users[1]
users[2]
После удаления users[1]:
users[0]
users[1]
Ошибки могут смещаться.
test(`user_${user.id}_email`, 'Некорректный email', () => {
enforce(user.email).matches(/^\S+@\S+\.\S+$/);
});
Такой подход более стабилен для интерфейсов с динамическими списками.
test('products', 'Список товаров пуст', () => {
enforce(data.products.length)
.greaterThan(0);
});
data.products.forEach((product, index) => {
test(
`products[${index}].title`,
'Введите название',
() => {
enforce(product.title).isNotBlank();
}
);
test(
`products[${index}].price`,
'Некорректная цена',
() => {
enforce(product.price).greaterThan(0);
}
);
test(
`products[${index}].stock`,
'Некорректный остаток',
() => {
enforce(product.stock)
.greaterThanOrEquals(0);
}
);
});
function validateEmails(emails) {
emails.forEach(email => {
enforce(email)
.matches(/^\S+@\S+\.\S+$/);
});
}
Использование:
test('emails', 'Некорректный email', () => {
validateEmails(data.emails);
});
const categories = [
{
title: 'Books',
children: [
{
title: 'Programming',
children: []
}
]
}
];
function validateCategory(categories) {
categories.forEach(category => {
enforce(category.title).isNotBlank();
if (category.children) {
validateCategory(category.children);
}
});
}
test('categories', 'Ошибка структуры категорий', () => {
validateCategory(data.categories);
});
test('users', 'Некорректный пользователь', () => {
data.users.forEach(user => {
enforce(user).shape({
name: value => enforce(value).isNotBlank(),
age: value => enforce(value).greaterThan(17)
});
});
});
Такой подход делает код более декларативным.
Проблемный код:
enforce(data.tags.length).greaterThan(0);
Если tags окажется null, произойдёт
ошибка.
Безопасный вариант:
enforce(data.tags).isArray();
enforce(data.tags.length).greaterThan(0);
Недостаточно:
enforce(data.users.length).greaterThan(0);
Необходимо также валидировать содержимое массива.
Плохой вариант:
test('users', 'Ошибка в списке пользователей', () => {
Лучше:
test(`users[${index}].email`, ...)
Тогда интерфейс сможет показать ошибку рядом с конкретным полем.
import vest, {
test,
enforce,
group
} from 'vest';
const suite = vest.create('order_form', (data = {}) => {
test('items', 'Добавьте товары', () => {
enforce(data.items).isArray();
enforce(data.items.length)
.greaterThan(0);
});
group('items', () => {
data.items.forEach((item, index) => {
test(
`items[${index}].title`,
'Введите название товара',
() => {
enforce(item.title)
.isNotBlank();
}
);
test(
`items[${index}].price`,
'Цена должна быть больше нуля',
() => {
enforce(item.price)
.greaterThan(0);
}
);
test(
`items[${index}].quantity`,
'Количество должно быть больше нуля',
() => {
enforce(item.quantity)
.greaterThan(0);
}
);
});
});
});