Введение: почему unit-тесты не спасают от всего
Представьте, что вы написали функцию сортировки массива. Вы покрыли её десятком юнит-тестов: пустой массив, один элемент, уже отсортированный список, обратный порядок. Тесты зелёные. Вы уверены в коде. Но через неделю продакшн падает — на массиве из 1000 элементов с дубликатами и отрицательными числами. Знакомо?
Традиционное тестирование TypeScript с помощью примеров (example-based testing) проверяет только те сценарии, которые вы придумали. А что, если баг прячется в комбинации, о которой вы не подумали? Здесь на сцену выходит property-based тестирование (тестирование на основе свойств) — подход, который автоматически генерирует тысячи случайных входных данных и проверяет инварианты вашего кода.
В этой статье мы разберём, как применять property-based testing в TypeScript с библиотекой fast-check, какие свойства стоит проверять и как это меняет подход к разработке.
Что такое property-based тестирование?
Вместо того чтобы писать:
it('сортирует [3, 1, 2] → [1, 2, 3]', () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
});
Вы описываете свойство (property) — утверждение, которое должно быть истинным для всех корректных входных данных:
it('отсортированный массив не меняет порядок элементов', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const sorted = sort(arr);
// Свойство 1: длина сохраняется
expect(sorted.length).toBe(arr.length);
// Свойство 2: каждый элемент исходного массива присутствует
// Свойство 3: массив отсортирован по возрастанию
for (let i = 1; i < sorted.length; i++) {
expect(sorted[i - 1]).toBeLessThanOrEqual(sorted[i]);
}
})
);
});
Библиотека fast-check сама сгенерирует сотни случайных массивов (включая пустые, с NaN, с дробными числами, с огромными значениями) и запустит ваш тест на каждом. Если хоть один упадёт — вы узнаете о проблеме.
Почему это нужно именно в TypeScript?
TypeScript даёт статическую типизацию, но она не гарантирует корректности логики. Property-based тестирование TypeScript закрывает разрыв между «типы сходятся» и «код работает правильно». Особенно это полезно для:
- Функций трансформации данных (map, filter, reduce)
- Парсеров и валидаторов
- Алгоритмов с нетривиальной логикой
- Функций, работающих с граничными значениями
Библиотека fast-check: быстрый старт
Установите fast-check в проект:
npm install --save-dev fast-check
Базовый синтаксис:
import fc from 'fast-check';
describe('свойства функции reverse', () => {
it('reverse(reverse(arr)) === arr', () => {
fc.assert(
fc.property(fc.array(fc.anything()), (arr) => {
expect(reverse(reverse(arr))).toEqual(arr);
})
);
});
});
Здесь fc.array(fc.anything()) — генератор произвольных массивов. Вы можете комбинировать генераторы:
fc.integer()— целые числаfc.string()— строкиfc.record({ name: fc.string(), age: fc.integer({ min: 0, max: 120 }) })— объектыfc.oneof(...)— случайный выбор из нескольких генераторов
Какие свойства проверять? 5 практичных примеров
1. Инвариантность (обратимость)
Любая операция, у которой есть обратная, должна быть обратимой:
it('сериализация и десериализация JSON', () => {
fc.assert(
fc.property(fc.object(), (obj) => {
const json = JSON.stringify(obj);
const parsed = JSON.parse(json);
expect(parsed).toEqual(obj);
})
);
});
2. Монолитность (idempotency)
Повторное применение функции не меняет результат:
it('trim должен быть идемпотентным', () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(str.trim().trim()).toBe(str.trim());
})
);
});
3. Сохранение структуры
Фильтрация не меняет порядок элементов, только удаляет некоторые:
it('filter сохраняет порядок исходного массива', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (arr) => {
const filtered = arr.filter(x => x > 0);
let j = 0;
for (let i = 0; i < arr.length; i++) {
if (arr[i] > 0) {
expect(filtered[j]).toBe(arr[i]);
j++;
}
}
})
);
});
4. Граничные значения
fast-check умеет генерировать «проблемные» значения (NaN, Infinity, null, undefined), если вы укажете соответствующий генератор:
it('parseInt не падает на любых строках', () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(() => parseInt(str)).not.toThrow();
})
);
});
Этот тест найдёт баги, связанные с неожиданными символами.
5. Коммутативность
Если операции можно менять местами — проверьте это:
it('сложение коммутативно', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
expect(a + b).toBe(b + a);
})
);
});
Продвинутые техники: кастомизация генераторов
Часто нужно тестировать не любые данные, а данные, соответствующие определённому формату. fast-check позволяет создавать свои генераторы:
// Генератор email-адресов
const emailArbitrary = fc
.record({
local: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789._%+-'), { minLength: 1, maxLength: 64 }),
domain: fc.stringOf(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789-'), { minLength: 1, maxLength: 255 }),
tld: fc.constantFrom('com', 'net', 'org', 'ru'),
})
.map(({ local, domain, tld }) => `${local}@${domain}.${tld}`);
it('валидатор email пропускает корректные адреса', () => {
fc.assert(
fc.property(emailArbitrary, (email) => {
expect(isValidEmail(email)).toBe(true);
})
);
});
Property-based vs Fuzz testing: в чём разница?
Часто эти понятия путают.
Блоги
Рекомендаций по книгам
Читайте также
Конкурентность и многопоточность в PHP: от теории к практике
Архитектурные паттерны в Kotlin: MVC, MVP, MVVM и MVI
Type 'string' is not assignable to type: как понять и исправить ошибку TypeScript
Парсинг и скрапинг данных на Kotlin: от новичка до реального проекта
Работа с базами данных в Swift: PostgreSQL и MongoDB для начинающих