Юнит-тесты для схем

При разработке структурированных валидационных слоёв на основе Superstruct ключевую роль играет проверка корректности самих схем. Ошибки в описании структур часто проявляются не сразу, а в продакшене, когда входные данные выходят за рамки ожидаемых форматов. Юнит-тесты позволяют зафиксировать поведение схем и гарантировать стабильность валидации при изменениях кода.

Особенности тестирования схем Superstruct

Схемы в Superstruct представляют собой функции-валидаторы, которые принимают данные и либо возвращают их в нормализованном виде, либо выбрасывают исключение. Это определяет специфику тестирования:

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

Типичная схема может выглядеть так:

import { object, string, number, validate } from 'superstruct';

const User = object({
  id: number(),
  name: string(),
});

Базовая структура юнит-тестов

Наиболее распространённый подход — использование Jest или Vitest. Тесты организуются вокруг двух основных групп случаев: валидные данные и невалидные данные.

import { validate } from 'superstruct';
import { User } from './schemas/User';

describe('User schema', () => {
  test('валидные данные проходят проверку', () => {
    const [error, result] = validate({
      id: 1,
      name: 'Alex',
    }, User);

    expect(error).toBeUndefined();
    expect(result).toEqual({
      id: 1,
      name: 'Alex',
    });
  });

  test('отсутствие поля вызывает ошибку', () => {
    const [error] = validate({
      id: 1,
    }, User);

    expect(error).toBeDefined();
  });
});

Проверка обязательных и опциональных полей

Особое внимание уделяется различию между обязательными и опциональными свойствами. В Superstruct опциональные поля задаются через optional.

import { object, string, optional } from 'superstruct';

const Profile = object({
  username: string(),
  bio: optional(string()),
});

Тестирование включает проверку обеих веток:

test('объект без опционального поля валиден', () => {
  const [error] = validate({
    username: 'user1',
  }, Profile);

  expect(error).toBeUndefined();
});

test('объект с опциональным полем валиден', () => {
  const [error] = validate({
    username: 'user1',
    bio: 'text',
  }, Profile);

  expect(error).toBeUndefined();
});

Граничные значения и типовые ошибки

Юнит-тесты должны охватывать граничные условия, поскольку именно в них чаще всего возникают дефекты валидации.

Примеры таких случаев:

  • пустые строки;
  • нулевые значения;
  • отрицательные числа;
  • чрезмерно большие числа;
  • неожиданные типы (null, undefined, объекты вместо строк).
test('пустая строка имени отклоняется', () => {
  const [error] = validate({
    id: 1,
    name: '',
  }, User);

  expect(error).toBeDefined();
});

test('null вместо числа вызывает ошибку', () => {
  const [error] = validate({
    id: null,
    name: 'Alex',
  }, User);

  expect(error).toBeDefined();
});

Тестирование вложенных структур

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

import { object, string, number, array } from 'superstruct';

const Post = object({
  id: number(),
  title: string(),
});

const Blog = object({
  name: string(),
  posts: array(Post),
});

Проверка вложенности:

test('валидный блог проходит проверку', () => {
  const [error] = validate({
    name: 'Tech Blog',
    posts: [
      { id: 1, title: 'Intro' },
      { id: 2, title: 'Advanced' },
    ],
  }, Blog);

  expect(error).toBeUndefined();
});

test('ошибка во вложенном объекте ломает всю структуру', () => {
  const [error] = validate({
    name: 'Tech Blog',
    posts: [
      { id: 1, title: 'Intro' },
      { id: 'bad', title: 'Advanced' },
    ],
  }, Blog);

  expect(error).toBeDefined();
});

Тестирование refine и кастомных валидаторов

refine используется для добавления пользовательских проверок, выходящих за рамки стандартных типов.

import { string, refine } from 'superstruct';

const Email = refine(string(), 'Email', (value) => {
  return value.includes('@');
});

Юнит-тесты должны проверять как успешные, так и провальные случаи:

test('валидный email проходит refine', () => {
  const [error] = validate('test@mail.com', Email);

  expect(error).toBeUndefined();
});

test('невалидный email отклоняется', () => {
  const [error] = validate('invalid-email', Email);

  expect(error).toBeDefined();
});

Проверка объединённых схем (union)

Union-типы часто используются для описания альтернативных структур данных.

import { union, string, number } from 'superstruct';

const Id = union([string(), number()]);

Тестирование должно учитывать все допустимые варианты:

test('строка допустима', () => {
  const [error] = validate('abc', Id);
  expect(error).toBeUndefined();
});

test('число допустимо', () => {
  const [error] = validate(123, Id);
  expect(error).toBeUndefined();
});

test('объект недопустим', () => {
  const [error] = validate({}, Id);
  expect(error).toBeDefined();
});

Регрессионные тесты схем

При росте проекта схемы часто изменяются: добавляются поля, усиливаются ограничения, меняются типы. Без регрессионных тестов такие изменения приводят к скрытым ошибкам.

Подход:

  • фиксируются реальные примеры входных данных;
  • сохраняется ожидаемый результат валидации;
  • при изменении схемы тесты показывают несовместимость.
const legacyPayload = {
  id: 10,
  name: 'Legacy User',
};

test('совместимость с устаревшим форматом данных', () => {
  const [error] = validate(legacyPayload, User);

  expect(error).toBeUndefined();
});

Параметризация тестов

Для сокращения дублирования кода используется параметризация.

describe('User schema invalid cases', () => {
  const cases = [
    { input: { id: '1', name: 'A' } },
    { input: { id: 1, name: null } },
    { input: {} },
  ];

  test.each(cases)('невалидный кейс %#', ({ input }) => {
    const [error] = validate(input, User);
    expect(error).toBeDefined();
  });
});

Проверка сообщений об ошибках

В некоторых сценариях важна не только фиксация ошибки, но и её структура. Superstruct возвращает детализированные ошибки, которые можно проверять.

const [error] = validate({ id: 'wrong', name: 'Alex' }, User);

expect(error.path).toEqual(['id']);

Такая проверка полезна при построении форм и API, где требуется точная диагностика проблемных полей.

Изоляция тестов схем

Схемы Superstruct должны тестироваться изолированно от бизнес-логики. Важно избегать:

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

Тестируется только чистая функция валидации.

Организация структуры тестов

При большом количестве схем применяется структурирование по слоям:

schemas/
  user.schema.test.js
  post.schema.test.js
  blog.schema.test.js

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

Стабильность схем как контракт

Юнит-тесты для Superstruct-схем фактически фиксируют контракт данных между слоями системы. Любое изменение структуры должно сопровождаться изменением тестов, иначе поведение становится непредсказуемым.

Такой подход превращает схемы в формализованный слой API-валидации, где тестирование выступает механизмом контроля целостности данных.