Property-based тестирование в TypeScript: как ловить баги, которые вы не искали

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: почему 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: в чём разница?

Часто эти понятия путают.

Блоги

Рекомендаций по книгам