Структура теста

В библиотеке Vest тест представляет собой не отдельную функцию проверки, а часть декларативного сценария валидации. Структура теста определяет:

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

Базовая единица структуры — вызов test().

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

const suite = create((data = {}) => {
  test('email', 'Некорректный email', () => {
    enforce(data.email).isEmail();
  });
});

Здесь:

Элемент Назначение
test регистрация теста
'email' имя валидируемого поля
'Некорректный email' сообщение ошибки
callback логика проверки

Сигнатура функции test

Полная структура:

test(fieldName, message, callback);

fieldName

Имя поля связывает тест с конкретным свойством объекта.

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

Vest использует имя поля для:

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

message

Сообщение ошибки может быть:

  • строкой;
  • функцией;
  • динамическим значением.

Пример со строкой:

test('username', 'Имя пользователя занято', () => {
  enforce(data.username).notEquals('admin');
});

Пример с функцией:

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

callback

Функция проверки содержит основной код валидации.

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

Если внутри callback возникает ошибка, Vest помечает тест как failed.


Внутреннее устройство теста

Каждый тест проходит несколько этапов:

  1. регистрация;
  2. вычисление зависимостей;
  3. запуск;
  4. перехват ошибок;
  5. обновление состояния suite;
  6. сохранение результата.

Схематично:

test()
   ↓
Регистрация поля
   ↓
Выполнение callback
   ↓
Ошибка?
 ├── Да → failed
 └── Нет → passed
   ↓
Обновление состояния

Синхронные тесты

Наиболее распространённая структура.

test('firstName', 'Введите имя', () => {
  enforce(data.firstName).isNotBlank();
});

Проверка завершается немедленно.


Асинхронные тесты

Vest поддерживает Promise и async/await.

Структура async теста

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

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

Во время выполнения тест получает состояние:

pending

После завершения:

passed / failed

Асинхронные ошибки

test('username', 'Ошибка проверки', async () => {
  const response = await fetch('/check');

  if (!response.ok) {
    throw new Error('Network error');
  }
});

Любое исключение внутри async callback интерпретируется как ошибка теста.


Логическая структура тестов

Vest строит дерево зависимостей между тестами.

Независимые тесты

test('email', 'Неверный email', () => {
  enforce(data.email).isEmail();
});

test('password', 'Слабый пароль', () => {
  enforce(data.password).longerThan(8);
});

Оба теста выполняются независимо.


Последовательные проверки

Несколько тестов могут относиться к одному полю.

test('password', 'Пароль обязателен', () => {
  enforce(data.password).isNotBlank();
});

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

test('password', 'Нужна цифра', () => {
  enforce(data.password).matches(/[0-9]/);
});

Vest сохраняет все ошибки отдельно.


Группировка тестов

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

import { group } from 'vest';

group('authentication', () => {
  test('email', 'Некорректный email', () => {
    enforce(data.email).isEmail();
  });

  test('password', 'Пароль обязателен', () => {
    enforce(data.password).isNotBlank();
  });
});

Группы используются для:

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

Условная структура тестов

when

Тест может зависеть от другого поля.

import { when } from 'vest';

when(data.country === 'US', () => {
  test('zip', 'Некорректный ZIP-код', () => {
    enforce(data.zip).matches(/^\d{5}$/);
  });
});

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


skip

Пропуск теста.

import { skip } from 'vest';

skip(!data.email, () => {
  test('email', 'Неверный email', () => {
    enforce(data.email).isEmail();
  });
});

Если условие истинно — тест не запускается.


omitWhen

Полное исключение блока тестов.

import { omitWhen } from 'vest';

omitWhen(data.isGuest, () => {
  test('password', 'Введите пароль', () => {
    enforce(data.password).isNotBlank();
  });
});

Разница между skip и omitWhen:

Метод Поведение
skip тест существует, но не выполняется
omitWhen тест полностью исключается

Структура fail-fast

Vest умеет прекращать выполнение после первой ошибки.

only

import { only } from 'vest';

only('email');

Будут выполняться только тесты поля email.


skipWhen

import { skipWhen } from 'vest';

skipWhen(res => res.hasErrors('email'), () => {
  test('password', 'Пароль слишком короткий', () => {
    enforce(data.password).shorterThan(8);
  });
});

Структура зависимой валидации:

email failed
   ↓
password skipped

Композиция тестов

Выделение функций

function validatePassword(password) {
  enforce(password).longerThanOrEquals(8);
  enforce(password).matches(/[A-Z]/);
}

test('password', 'Слабый пароль', () => {
  validatePassword(data.password);
});

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

function required(field, value) {
  test(field, `${field} обязателен`, () => {
    enforce(value).isNotBlank();
  });
}

required('email', data.email);
required('username', data.username);

Вложенная структура

Vest допускает композицию внутри callback.

group('profile', () => {
  test('firstName', 'Введите имя', () => {
    enforce(data.firstName).isNotBlank();
  });

  group('contacts', () => {
    test('email', 'Некорректный email', () => {
      enforce(data.email).isEmail();
    });
  });
});

Это особенно полезно в больших формах.


Динамические тесты

Тесты могут создаваться во время выполнения.

Генерация через массив

const fields = ['email', 'username', 'password'];

fields.forEach(field => {
  test(field, `${field} обязателен`, () => {
    enforce(data[field]).isNotBlank();
  });
});

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

const rules = {
  username: value => enforce(value).longerThan(3),
  password: value => enforce(value).longerThan(8),
};

Object.entries(rules).forEach(([field, validator]) => {
  test(field, 'Ошибка валидации', () => {
    validator(data[field]);
  });
});

Структура ошибок

Каждый тест хранит:

{
  fieldName,
  message,
  status,
  async,
  pending
}

Пример:

{
  fieldName: 'email',
  message: 'Некорректный email',
  status: 'failed',
  async: false,
  pending: false
}

Поведение исключений

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

Успешный тест

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

Если исключение не возникло:

status = passed

Неуспешный тест

test('age', 'Возраст должен быть больше 18', () => {
  throw new Error();
});

Результат:

status = failed

Взаимодействие со state

После выполнения suite:

const result = suite(data);

Доступны методы:

result.hasErrors();
result.getErrors();
result.isValid();

Структура тестов напрямую влияет на содержимое state.


Структура partial validation

Vest умеет запускать только часть тестов.

suite(data, 'email');

Будут выполнены только тесты поля email.

Это особенно важно для:

  • больших форм;
  • live validation;
  • оптимизации производительности.

Структура eager validation

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

input.addEventListener('input', e => {
  suite({ email: e.target.value }, 'email');
});

Структура тестов должна быть:

  • независимой;
  • изолированной;
  • без побочных эффектов.

Побочные эффекты внутри тестов

Нежелательная структура:

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

Причины:

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

Корректный подход:

test('email', 'Некорректный email', () => {
  enforce(data.email).isEmail();
});

Именование тестов

Хорошая структура имён

test('billingAddress', 'Введите адрес', () => {});
test('confirmPassword', 'Пароли не совпадают', () => {});

Нежелательные имена

test('field1', 'Ошибка', () => {});
test('x', 'Invalid', () => {});

Имя поля должно:

  • отражать структуру данных;
  • быть стабильным;
  • быть предсказуемым.

Масштабирование структуры

Разделение по модулям

// userSuite.js
export function validateUser(data) {
  test('email', 'Некорректный email', () => {
    enforce(data.email).isEmail();
  });
}
// mainSuite.js
create(data => {
  validateUser(data);
});

Архитектура больших validation suite

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

validation/
├── user/
│   ├── email.js
│   ├── password.js
│   └── profile.js
├── billing/
├── shipping/
└── shared/

Порядок выполнения тестов

Vest выполняет тесты сверху вниз.

test('a', 'A', () => {});
test('b', 'B', () => {});
test('c', 'C', () => {});

Порядок:

a → b → c

Однако async тесты завершаются независимо.


Структура async queue

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

Внутренне:

register
   ↓
start async jobs
   ↓
pending state
   ↓
resolve independently

Структура memoization

Vest кэширует результаты тестов.

Повторный запуск:

suite(data);
suite(data);

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

Это снижает:

  • количество вычислений;
  • нагрузку на UI;
  • число async запросов.

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

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

export const suite = create(data => {
  group('auth', () => {
    test('email', 'Введите email', () => {
      enforce(data.email).isNotBlank();
    });

    test('email', 'Некорректный email', () => {
      enforce(data.email).isEmail();
    });

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

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

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

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