Традиційне тестування на основі прикладів (Example-Based Testing) страждає від підтверджувального упередження (Confirmation Bias): розробник схильний перевіряти лише ті граничні умови, про які він уже подумав під час написання коду. У результаті неочевидні граничні випадки — порожні масиви, сурогатні пари Unicode, від'ємний нуль (-0), NaN, переповнення точності чисел чи спецсимволи — проникають у продакшн.
Property-Based Testing (PBT) змінює цю парадигму. Розробник формулює:
fc.string(), fc.integer(), fc.record()).reverse(reverse(list)) === list).\0 або числа -101).+-------------------------------------------------------------------------+
| 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: "" |
+-----------------+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.
Якщо для перевірки функції ви дублюєте її внутрішню логіку всередині 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
})
);Використання неконтрольованих генераторів призводить до того, що більшість згенерованих значень відсікаються валідаторами типів, не доходячи до цільової логіки.
// ❌ Помилка: передача довільних об'єктів у функцію, що очікує 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();
})
);Визначити ключову властивість: Roundtrip (оборотність), Idempotence (стабільність повтору) або Invariant (збереження меж/знаків).
Скомбінувати базові примітиви (fc.string, fc.integer, fc.array) у доменні типи за допомогою fc.record.
Запустити fc.assert(fc.property(...)) на 100-500 ітерацій у звичайному сьюті Vitest.
При виявленні бага вивчити мінімальний контрприклад і додати його до регресійного тестового набору.