Традиционное тестирование на основе примеров (Example-Based Testing) страдает от подтверждающего искажения (Confirmation Bias): человек склонен проверять только те граничные условия, о которых он уже подумал при написании функции. В результате редкие граничные кейсы — пустые массивы, суррогатные пары Unicode, отрицательный ноль (-0), NaN, переполнение числовой точности или спецсимволы — проникают в продакшн.
Property-Based Testing (PBT) меняет эту парадигму. Разработчик задает:
fc.string(), fc.integer(), fc.record()).reverse(reverse(list)) === list).\0).+-------------------------------------------------------------------------+
| Property-Based Testing Loop (fast-check) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------+
| Arbitrary Generator (100+ runs) |
| fc.record({ id: fc.uuid(), ... }) |
+-------------------------------------+
|
v
+-------------------------------------+
| Execute Invariant Test |
| assert(isInvariantHold(x)) |
+-------------------------------------+
|
+---------------+---------------+
| |
[Все тесты прошли] [Тест упал]
| |
v v
+-----------------+ +-----------------+
| PASSED (100) | | SHRINKING LOOP |
+-----------------+ | Упрощение входа |
+-----------------+
|
v
+-----------------+
| Min Counter-ex: |
| e.g. input: "" |
+-----------------+Определить ключевое свойство: Roundtrip (обратимость), Idempotence (стабильность повтора) или Invariant (сохранение длины/знака).
Скомбинировать базовые примитивы (fc.string, fc.integer, fc.array) в доменные типы через fc.record.
Запустить fc.assert(fc.property(...)) на 100-500 итераций в стандартном сьюте Vitest.
При обнаружении бага изучить минимальный контрпример и добавить его в регрессионный тест-набор.
Какое фундаментальное преимущество обеспечивает механизм Shrinking в библиотеке fast-check при обнаружении сбоя инварианта?
parse(serialize(x)) === x.clean(clean(x)) === clean(x).0 <= applyDiscount(price, rate) <= price.Свойство Roundtrip (обратимость) утверждает: операция decode(encode(data)) должна восстанавливать исходное состояние данных без потерь.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export interface QueryState {
filter: string;
page: number;
isActive: boolean;
}
export function serializeQuery(state: QueryState): string {
const params = new URLSearchParams();
params.set('filter', state.filter);
params.set('page', state.page.toString());
params.set('isActive', state.isActive ? '1' : '0');
return params.toString();
}
export function parseQuery(queryString: string): QueryState {
const params = new URLSearchParams(queryString);
const filter = params.get('filter') ?? '';
const pageRaw = parseInt(params.get('page') ?? '1', 10);
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
const isActive = params.get('isActive') === '1';
return { filter, page, isActive };
}
describe('Roundtrip: serializeQuery <-> parseQuery', () => {
it('гарантирует точное восстановление состояния после кодирования и декодирования', () => {
// Описываем генератор произвольного валидного состояния QueryState
const queryArbitrary = fc.record<QueryState>({
// Любые строки, включая пробелы, амперсанды, эмодзи и спецсимволы
filter: fc.string(),
// Натуральные числа страниц от 1 до 1 000 000
page: fc.integer({ min: 1, max: 1_000_000 }),
isActive: fc.boolean(),
});
fc.assert(
fc.property(queryArbitrary, (originalState) => {
const serialized = serializeQuery(originalState);
const parsed = parseQuery(serialized);
// Инвариант: декодированный объект идентичен исходному
expect(parsed).toEqual(originalState);
}),
{ numRuns: 200 } // Прогон 200 уникальных комбинаций входных данных
);
});
});Функция нормализации (например, удаление дублирующихся пробелов и приведение к нижнему регистру) должна быть идемпотентной: ее повторный вызов к уже обработанному результату ничего не должен менять.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
export function normalizeSearchQuery(input: string): string {
return input
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
describe('Идемпотентность: normalizeSearchQuery', () => {
it('гарантирует стабильность результата при повторной обработке f(f(x)) === f(x)', () => {
fc.assert(
fc.property(fc.fullUnicodeString(), (rawInput) => {
const firstPass = normalizeSearchQuery(rawInput);
const secondPass = normalizeSearchQuery(firstPass);
// Инвариант идемпотентности
expect(secondPass).toBe(firstPass);
})
);
});
});Предположим, в алгоритме форматирования валюты допущен дефект: при отрицательных числах знак минуса дублируется, если число меньше -100.
import { describe, it, expect } from 'vitest';
import fc from 'fast-check';
// Функция с намеренным багом на граничном значении
export function formatCurrencyCents(cents: number): string {
const isNegative = cents < 0;
const absCents = Math.abs(cents);
const dollars = Math.floor(absCents / 100);
const remainder = absCents % 100;
const formattedNumber = `${dollars}.${remainder.toString().padStart(2, '0')}`;
// Ошибка: при cents < -100 добавляется лишний дефис
if (cents < -100) {
return `--$${formattedNumber}`;
}
return isNegative ? `-$${formattedNumber}` : `$${formattedNumber}`;
}
describe('formatCurrencyCents (демонстрация Shrinking)', () => {
it('всегда содержит не более одного знака минус', () => {
const property = fc.property(fc.integer(), (cents) => {
const formatted = formatCurrencyCents(cents);
const minusCount = (formatted.match(/-/g) ?? []).length;
expect(minusCount).toBeLessThanOrEqual(1);
});
// При падении fast-check выдаст минимальный контрпример
expect(() => fc.assert(property)).toThrowError();
});
});При запуске этого теста fast-check может сначала наткнуться на падение при числе -89437283. Затем запускается процесс Shrinking:
-44718641 (падает).-1000 (падает).-101 (падает).-100 (проходит успешно).Раннер выведет четкий диагностический отчет:
Property failed after 14 tests
{ seed: 1892847293, path: "13:0:2:1", endOnFailure: true }
Counterexample: [-101]
Shrunk 28 time(s)Разработчик сразу видит: пограничное условие сбоя — ровно -101.
Что необходимо зафиксировать при локальной отладке Property-based теста, упавшего в CI/CD пайплайне на сервере GitHub Actions?
Если для проверки функции вы дублируете всю ее логику внутри тела fc.property, вы тестируете совпадение двух одинаковых реализаций, а не инвариант системы.
// ❌ Антипаттерн: тест повторяет логику реализации
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
// Тест просто дублирует саму функцию add(a, b)
expect(add(a, b)).toBe(a + b);
})
);
// ✅ Правильное решение: проверка математических свойств (коммутативность, ассоциативность)
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(add(a, b)).toBe(add(b, a)); // Коммутативность: a + b === b + a
expect(add(a, 0)).toBe(a); // Нейтральный элемент: a + 0 === a
})
);Использование произвольных генераторов без учета доменных инвариантов приводит к тому, что 90% сгенерированных значений отсекаются или вызывают нерелевантные ошибки типов.
// ❌ Ошибка: передача произвольных объектов в функцию, ожидающую email
fc.assert(fc.property(fc.anything(), (input) => { ... }));
// ✅ Правильное решение: сфокусированный арбитраж с доменными ограничениями
const emailArbitrary = fc.emailAddress();
fc.assert(fc.property(emailArbitrary, (email) => { ... }));Если тест упал на сервере CI, запуск локально может пройти успешно из-за другого псевдослучайного зерна. Для детерминированного воспроизведения необходимо передавать зафиксированный seed.
// ✅ Фиксация seed для точного воспроизведения упавшего кейса из логов CI
fc.assert(property, {
seed: 1892847293, // Значение seed берется напрямую из отчета об ошибке
});Если тестируемый метод возвращает Promise, использование синхронного fc.property приведет к тому, что тест завершится до выполнения проверок, пропустив реальные ошибки.
// ❌ Ошибка: синхронный property не дожидается Promise
fc.assert(
fc.property(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined(); // Проверка выполнится уже после окончания теста!
})
);
// ✅ Правильное решение: использование fc.asyncProperty
fc.assert(
fc.asyncProperty(fc.string(), async (str) => {
const res = await asyncParser(str);
expect(res).toBeDefined();
})
);