Вложенные тесты

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

  • объекты внутри объектов;
  • массивы элементов;
  • повторяющиеся группы полей;
  • динамические секции;
  • сложные связи между данными.

В таких случаях Vest предоставляет механизм вложенных тестов, позволяющий организовать проверки в виде логических блоков.

Основная цель вложенных тестов — сгруппировать связанные проверки и построить иерархическую структуру валидации.


Проблема плоской структуры тестов

Без вложенности тесты быстро превращаются в длинный линейный список:

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

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

test('zip', 'Укажите индекс', () => {
  enforce(data.zip).matches(/^\d{6}$/);
});

test('cardNumber', 'Номер карты неверен', () => {
  enforce(data.cardNumber).matches(/^\d{16}$/);
});

test('cvv', 'CVV неверен', () => {
  enforce(data.cvv).matches(/^\d{3}$/);
});

При увеличении количества секций возникают проблемы:

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

Вложенные тесты решают эти ограничения.


Группировка проверок через group

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

Базовый пример

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

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

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

    test('zip', 'Неверный индекс', () => {
      enforce(data.zip).matches(/^\d{6}$/);
    });
  });

  group('payment', () => {
    test('cardNumber', 'Неверный номер карты', () => {
      enforce(data.cardNumber).matches(/^\d{16}$/);
    });

    test('cvv', 'Неверный CVV', () => {
      enforce(data.cvv).matches(/^\d{3}$/);
    });
  });
});

Что делает group

Функция group:

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

Вложенные группы

Группы могут быть вложенными друг в друга.

Пример многоуровневой структуры

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

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

    group('payment', () => {
      group('card', () => {
        test('number', 'Неверный номер карты', () => {
          enforce(data.payment.card.number).matches(/^\d{16}$/);
        });

        test('cvv', 'Неверный CVV', () => {
          enforce(data.payment.card.cvv).matches(/^\d{3}$/);
        });
      });
    });
  });
});

Работа с массивами

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

Например:

const users = [
  {
    name: 'Alex',
    email: 'alex@mail.com'
  },
  {
    name: '',
    email: 'wrong-email'
  }
];

Валидация массива через циклы

const suite = create((data) => {
  data.users.forEach((user, index) => {
    group(`user_${index}`, () => {
      test(`name_${index}`, 'Имя обязательно', () => {
        enforce(user.name).isNotBlank();
      });

      test(`email_${index}`, 'Email некорректен', () => {
        enforce(user.email).isEmail();
      });
    });
  });
});

Зачем использовать группы в массивах

Без группировки:

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

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


Динамические вложенные секции

Многие формы содержат динамические блоки:

  • список адресов;
  • список товаров;
  • список телефонов;
  • список участников;
  • список документов.

Vest позволяет валидировать их динамически.


Пример формы заказа

const suite = create((data) => {
  data.items.forEach((item, index) => {
    group(`item_${index}`, () => {
      test(`title_${index}`, 'Название обязательно', () => {
        enforce(item.title).isNotBlank();
      });

      test(`price_${index}`, 'Цена должна быть больше нуля', () => {
        enforce(item.price).greaterThan(0);
      });

      test(`quantity_${index}`, 'Количество должно быть больше нуля', () => {
        enforce(item.quantity).greaterThan(0);
      });
    });
  });
});

Повторное использование вложенных тестов

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

Vest позволяет выносить группы в отдельные функции.


Валидация адреса

function validateAddress(address) {
  group('address', () => {
    test('street', 'Улица обязательна', () => {
      enforce(address.street).isNotBlank();
    });

    test('city', 'Город обязателен', () => {
      enforce(address.city).isNotBlank();
    });

    test('zip', 'Индекс неверен', () => {
      enforce(address.zip).matches(/^\d{6}$/);
    });
  });
}

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

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

Изоляция логики

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

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

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

Хороший подход

const suite = create((data) => {
  validateProfile(data.profile);
  validateContacts(data.contacts);
  validateDocuments(data.documents);
  validatePayment(data.payment);
});

Каждая функция содержит собственные вложенные группы.


Условные вложенные тесты

Вложенные проверки особенно полезны при условной логике.


Пример

const suite = create((data) => {
  test('type', 'Выберите тип аккаунта', () => {
    enforce(data.type).isNotBlank();
  });

  if (data.type === 'company') {
    group('company', () => {
      test('companyName', 'Название компании обязательно', () => {
        enforce(data.companyName).isNotBlank();
      });

      test('taxId', 'ИНН обязателен', () => {
        enforce(data.taxId).isNotBlank();
      });
    });
  }
});

Вложенные тесты и асинхронная валидация

Vest поддерживает асинхронные проверки внутри групп.


Проверка пользователей

const suite = create(async (data) => {
  group('users', () => {
    data.users.forEach((user, index) => {
      test(`email_${index}`, 'Email уже существует', async () => {
        const exists = await checkEmail(user.email);

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

Работа с путями данных

При сложной вложенности полезно использовать пути объектов.


Пример

test('profile.address.city', 'Город обязателен', () => {
  enforce(data.profile.address.city).isNotBlank();
});

Польза такого подхода

Иерархические имена:

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

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

Vest хорошо подходит для композиции.


Валидатор телефона

function validatePhone(phone, prefix) {
  group(prefix, () => {
    test(`${prefix}.countryCode`, 'Код страны обязателен', () => {
      enforce(phone.countryCode).isNotBlank();
    });

    test(`${prefix}.number`, 'Номер обязателен', () => {
      enforce(phone.number).isNotBlank();
    });
  });
}

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

const suite = create((data) => {
  validatePhone(data.homePhone, 'homePhone');
  validatePhone(data.workPhone, 'workPhone');
});

Ошибки при использовании вложенных тестов

Конфликт имён

Плохой пример:

group('user', () => {
  test('email', 'Ошибка', () => {});
});

group('admin', () => {
  test('email', 'Ошибка', () => {});
});

В больших системах одинаковые имена могут затруднить диагностику.


Лучший вариант

group('user', () => {
  test('user.email', 'Ошибка', () => {});
});

group('admin', () => {
  test('admin.email', 'Ошибка', () => {});
});

Чрезмерная вложенность

Слишком глубокая структура усложняет поддержку.


Нежелательный пример

group('a', () => {
  group('b', () => {
    group('c', () => {
      group('d', () => {
        group('e', () => {
          // ...
        });
      });
    });
  });
});

Рекомендации

Оптимальная вложенность:

  • 2–4 уровня;
  • логическое разделение;
  • независимые блоки;
  • читаемые имена.

Вложенные тесты и UI

Структурированные проверки особенно полезны при интеграции с интерфейсами.


Пример структуры ошибок

{
  profile: {
    email: ['Email неверен']
  },
  address: {
    city: ['Город обязателен']
  }
}

Такая структура:

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

Масштабирование больших форм

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


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

const suite = create((data) => {
  validateAccount(data.account);
  validateProfile(data.profile);
  validateSecurity(data.security);
  validateNotifications(data.notifications);
  validateBilling(data.billing);
});

Внутри каждого валидатора

function validateSecurity(security) {
  group('security', () => {
    test('security.password', 'Пароль слишком короткий', () => {
      enforce(security.password).longerThanOrEquals(8);
    });

    test('security.confirmPassword', 'Пароли не совпадают', () => {
      enforce(security.confirmPassword).equals(security.password);
    });
  });
}

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

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


Пример

import { each } from 'vest';

const suite = create((data) => {
  each(data.users, (user, index) => {
    group(`user_${index}`, () => {
      test(`email_${index}`, 'Email неверен', () => {
        enforce(user.email).isEmail();
      });
    });
  });
});

Организация файлов

При работе с вложенными тестами полезно разделять валидаторы по доменам.


Пример структуры проекта

validation/
├── account/
│   ├── accountSuite.js
│   ├── profileValidation.js
│   ├── securityValidation.js
│   └── addressValidation.js
│
├── order/
│   ├── orderSuite.js
│   ├── itemsValidation.js
│   └── paymentValidation.js

Практические рекомендации

Использовать логические домены

Правильно:

group('billing', () => {});
group('shipping', () => {});

Неправильно:

group('group1', () => {});
group('group2', () => {});

Делать имена предсказуемыми

Хорошая структура:

profile.email
profile.phone
profile.address.city

Избегать дублирования

Повторяющиеся группы необходимо выносить в функции.


Разделять синхронные и асинхронные проверки

Это облегчает:

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

Полный пример сложной вложенной структуры

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

function validateAddress(address, prefix) {
  group(prefix, () => {
    test(`${prefix}.street`, 'Улица обязательна', () => {
      enforce(address.street).isNotBlank();
    });

    test(`${prefix}.city`, 'Город обязателен', () => {
      enforce(address.city).isNotBlank();
    });

    test(`${prefix}.zip`, 'Индекс неверен', () => {
      enforce(address.zip).matches(/^\d{6}$/);
    });
  });
}

function validateUser(user, index) {
  group(`user_${index}`, () => {
    test(`user_${index}.name`, 'Имя обязательно', () => {
      enforce(user.name).isNotBlank();
    });

    test(`user_${index}.email`, 'Email неверен', () => {
      enforce(user.email).isEmail();
    });

    validateAddress(user.address, `user_${index}.address`);
  });
}

const suite = create((data) => {
  data.users.forEach((user, index) => {
    validateUser(user, index);
  });
});

Такой подход обеспечивает:

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