Property-based testing (PBT) опирается не на заранее заданные примеры входных данных, а на формулирование свойств (properties), которые должны выполняться для широкого диапазона автоматически сгенерированных значений. Такой подход особенно полезен в системах валидации, где важно не столько проверить несколько «счастливых» кейсов, сколько доказать устойчивость правил к множеству пограничных и неожиданных входов.
В экосистеме JavaScript библиотека Superstruct решает задачу описания структур данных и их валидации. В сочетании с property-based testing она становится инструментом не только проверки корректности данных, но и формального описания инвариантов доменной модели.
Традиционные unit-тесты фиксируют конкретные входные данные:
expect(validateUser({ name: "Alice", age: 30 })).toBe(true);
Property-based testing заменяет это утверждением общего характера:
Такие свойства проверяются на сотнях или тысячах автоматически сгенерированных входов.
В 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 в 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;
})
);
Здесь формулируется ключевое свойство:
Схема Superstruct задаёт пространство допустимых значений. Property-based testing проверяет, что:
Это создаёт двустороннюю проверку:
Распространённая проблема — несовпадение ограничений:
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 выступает как инструмент синхронизации между моделью данных и тестовыми генераторами.
Одно из мощных применений — проверка преобразований данных:
Пример свойства:
если значение проходит 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;
})
);
Такой подход усиливает контроль над спецификацией типов.
Библиотека fast-check обладает механизмом shrink — автоматического упрощения входных данных при падении теста.
При интеграции с Superstruct это особенно полезно:
Superstruct-схемы сами по себе описывают допустимые данные, но property-based testing добавляет к ним проверяемую семантику:
Таким образом, схема перестаёт быть статическим описанием и становится исполняемым контрактом, проверяемым на множестве автоматически сгенерированных сценариев