Property-based testing

Property-based testing (PBT) опирается не на заранее заданные примеры входных данных, а на формулирование свойств (properties), которые должны выполняться для широкого диапазона автоматически сгенерированных значений. Такой подход особенно полезен в системах валидации, где важно не столько проверить несколько «счастливых» кейсов, сколько доказать устойчивость правил к множеству пограничных и неожиданных входов.

В экосистеме JavaScript библиотека Superstruct решает задачу описания структур данных и их валидации. В сочетании с property-based testing она становится инструментом не только проверки корректности данных, но и формального описания инвариантов доменной модели.

Модель свойств вместо фиксированных примеров

Традиционные unit-тесты фиксируют конкретные входные данные:

expect(validateUser({ name: "Alice", age: 30 })).toBe(true);

Property-based testing заменяет это утверждением общего характера:

  • имя пользователя всегда строка длиной от 1 до 50 символов
  • возраст всегда целое число от 0 до 120
  • функция валидации никогда не принимает отрицательный возраст

Такие свойства проверяются на сотнях или тысячах автоматически сгенерированных входов.

Superstruct как описание доменных инвариантов

В Superstruct структура описывается декларативно:

import { object, string, number, min, max } from "superstruct";

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

Дальнейшее уточнение ограничений формирует инварианты:

import { refine } from "superstruct";

const PositiveNumber = refine(number(), "PositiveNumber", (value) => {
  return value >= 0;
});

const User = object({
  name: string(),
  age: PositiveNumber,
});

Эта модель уже содержит формализованные свойства, которые можно использовать как основу для генерации тестов.

Генерация данных для property-based testing

Для property-based testing в JavaScript часто используется библиотека fast-check, которая предоставляет генераторы произвольных значений (arbitraries).

Связка с Superstruct строится через генерацию структур, совместимых с описанными схемами.

Простейший подход — построение arbitraries вручную:

import * as fc from "fast-check";

const userArbitrary = fc.record({
  name: fc.string({ minLength: 1, maxLength: 50 }),
  age: fc.integer({ min: 0, max: 120 }),
});

Далее проверка свойства через Superstruct:

import { validate } from "superstruct";

fc.assert(
  fc.property(userArbitrary, (user) => {
    const [error] = validate(user, User);
    return error === undefined;
  })
);

Здесь формулируется ключевое свойство:

  • любое значение, сгенерированное по arbitrary, должно проходить Superstruct-валидацию

Инварианты как обратная сторона схемы

Схема Superstruct задаёт пространство допустимых значений. Property-based testing проверяет, что:

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

Это создаёт двустороннюю проверку:

  1. Superstruct определяет контракт
  2. fast-check генерирует данные
  3. тест подтверждает согласованность между ними

Выявление расхождений между генератором и схемой

Распространённая проблема — несовпадение ограничений:

const User = object({
  age: refine(number(), "Age", (v) => v >= 18),
});

Если генератор случайно создаёт отрицательные числа:

fc.integer({ min: -100, max: 100 });

property-based тест быстро выявит несоответствие.

Правильная формулировка генератора:

fc.integer({ min: 18, max: 100 });

Таким образом, property-based testing выступает как инструмент синхронизации между моделью данных и тестовыми генераторами.

Проверка обратимости структур (round-trip properties)

Одно из мощных применений — проверка преобразований данных:

  • сериализация / десериализация
  • нормализация входов
  • приведение типов

Пример свойства:

если значение проходит Superstruct-валидацию, то после сериализации и обратного разбора оно остаётся валидным

fc.assert(
  fc.property(userArbitrary, (user) => {
    const [err] = validate(user, User);
    if (err) return true;

    const serialized = JSON.parse(JSON.stringify(user));
    const [err2] = validate(serialized, User);

    return err2 === undefined;
  })
);

Интеграция кастомных предикатов и генераторов

В Superstruct часто используются refine и пользовательские проверки. Для property-based testing важно синхронизировать их с генерацией.

Пример:

const EvenNumber = refine(number(), "EvenNumber", (v) => v % 2 === 0);

Соответствующий arbitrary:

const evenArbitrary = fc.integer().map((n) => n * 2);

Без такой синхронизации тесты будут нестабильны и давать ложные падения.

Композиция структур и сложные доменные модели

При работе с вложенными структурами property-based testing становится особенно полезным.

const Address = object({
  city: string(),
  zip: string(),
});

const User = object({
  name: string(),
  age: PositiveNumber,
  address: Address,
});

Генератор:

const addressArb = fc.record({
  city: fc.string({ minLength: 1, maxLength: 30 }),
  zip: fc.string({ minLength: 4, maxLength: 10 }),
});

const userArb = fc.record({
  name: fc.string({ minLength: 1, maxLength: 50 }),
  age: fc.integer({ min: 0, max: 120 }),
  address: addressArb,
});

Property:

fc.assert(
  fc.property(userArb, (user) => {
    return validate(user, User)[0] === undefined;
  })
);

Проверка негативных сценариев

Property-based testing позволяет формализовать отрицательные свойства:

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

Пример:

fc.assert(
  fc.property(fc.anything(), (value) => {
    const [err] = validate(value, User);

    const isValidShape =
      typeof value === "object" &&
      value !== null &&
      typeof value.name === "string" &&
      typeof value.age === "number";

    return isValidShape || err !== undefined;
  })
);

Такой подход усиливает контроль над спецификацией типов.

Использование shrink-логики для диагностики ошибок

Библиотека fast-check обладает механизмом shrink — автоматического упрощения входных данных при падении теста.

При интеграции с Superstruct это особенно полезно:

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

Свойства как формальная документация схем

Superstruct-схемы сами по себе описывают допустимые данные, но property-based testing добавляет к ним проверяемую семантику:

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

Таким образом, схема перестаёт быть статическим описанием и становится исполняемым контрактом, проверяемым на множестве автоматически сгенерированных сценариев