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

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

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

Начать курс

Что, если тесты пишут сами себя? Знакомство с property-based testing

Вы когда-нибудь писали юнит-тест, запускали его, получали зеленую галочку, а через месяц находили баг в той же функции? Знакомо? Классические юнит-тесты — это как проверять, что ваш кошелек не пуст, глядя только на одну купюру. Вы проверяете один конкретный сценарий, один набор входных данных. А что, если я скажу вам, что есть способ заставить компьютер самому придумывать тысячи разных сценариев и проверять, не сломается ли ваш код?

Это не фантастика. Это property-based тестирование в JavaScript (или тестирование на основе свойств). Вместо того чтобы писать «если передать 2 и 3, получим 5», вы описываете свойство (property) вашей функции: «для любых двух чисел, результатом сложения будет число, которое всегда больше или равно каждому из слагаемых». И тестовая среда генерирует сотни случайных пар чисел, проверяя это утверждение.

В этой статье мы разберем, как работает property-based тестинг, чем он отличается от обычных тестов, и как внедрить его в ваш проект с помощью крутой библиотеки fast-check. Вы увидите, как легко находить «пограничные» баги, которые вы даже не подумали бы искать.

Почему обычные юнит-тесты не всегда спасают?

Давайте сразу к делу. Допустим, у вас есть функция, которая сортирует массив чисел по возрастанию:

function sortNumbers(arr) {
  return arr.slice().sort((a, b) => a - b);
}

Вы пишете юнит-тест:

test('сортирует массив [3, 1, 2]', () => {
  expect(sortNumbers([3, 1, 2])).toEqual([1, 2, 3]);
});

Тест зеленый. Вы счастливы. Но что, если в массиве есть null? Или undefined? Или очень большие числа? Или массив длиной в 10000 элементов? Вы не будете писать тест на каждый из этих случаев. А property-based тестирование в JavaScript — будет.

Вместо того чтобы перечислять примеры, вы описываете инварианты (свойства), которые всегда должны быть истинны:

  1. Длина отсортированного массива равна длине исходного.
  2. Каждый элемент исходного массива присутствует в отсортированном.
  3. Для любого индекса i верно, что arr[i] <= arr[i+1].

Именно эти свойства и будет проверять тестирование свойств.

Библиотека fast-check: ваш помощник в рандомном тестировании

В мире JavaScript главный инструмент для property-based testing — это fast-check. Она умеет генерировать случайные данные любых типов: числа, строки, массивы, объекты, даты — и даже сложные структуры.

Установка проста:

npm install fast-check --save-dev

Теперь напишем наш первый property-based тест для функции sortNumbers. Мы используем фреймворк Jest, но fast-check работает с любым тест-раннером.

const fc = require('fast-check');

test('сортировка сохраняет длину массива', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = sortNumbers(arr);
      return sorted.length === arr.length;
    })
  );
});

Что здесь происходит? fc.array(fc.integer()) — это генератор, который создает случайные массивы случайных целых чисел. fc.property берет этот генератор и проверяет утверждение (нашу стрелочную функцию) на 100 (по умолчанию) разных наборах данных. Если хотя бы один тест упадет — fc.assert выбросит ошибку и покажет контрпример.

Добавляем больше свойств

Одно свойство — хорошо, а три — лучше. Давайте проверим, что после сортировки массив действительно упорядочен:

test('сортировка возвращает упорядоченный массив', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = sortNumbers(arr);
      for (let i = 1; i < sorted.length; i++) {
        if (sorted[i - 1] > sorted[i]) return false;
      }
      return true;
    })
  );
});

И еще одно — что все элементы исходного массива остались в отсортированном (проверка на потерю данных):

test('сортировка не теряет элементы', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = sortNumbers(arr);
      // Простая проверка: каждый элемент из arr есть в sorted
      return arr.every(item => sorted.includes(item));
    })
  );
});

Запустите эти тесты. Они пройдут. Но теперь представьте, что кто-то случайно изменил функцию сортировки на arr.sort() (лексикографическая сортировка!). Ваши property-тесты мгновенно найдут проблему, потому что свойство «массив упорядочен по возрастанию чисел» нарушится для массива [10, 2] (он превратится в [10, 2]).

Реальный пример: тестирование пароля

Перейдем от абстракций к практике. Допустим, у нас есть функция, которая проверяет сложность пароля:

function isStrongPassword(password) {
  if (password.length < 8) return false;
  if (!/[A-Z]/.test(password)) return false;
  if (!/[a-z]/.test(password)) return false;
  if (!/[0-9]/.test(password)) return false;
  return true;
}

Какие свойства мы можем проверить?

  • Если пароль длиннее 50 символов и содержит все необходимые символы, он должен быть сильным.
  • Если пароль состоит только из цифр, он не должен быть сильным.
  • Если пароль короче 8 символов, он не должен быть сильным.

Пишем property-based тест для первого свойства:

test('длинный пароль с разными символами — сильный', () => {
  fc.assert(
    fc.property(
      fc.string({ minLength: 50, maxLength: 100 }),
      fc.constantFrom('A', 'B', 'C'),
      fc.constantFrom('a', 'b', 'c'),
      fc.constantFrom('1', '2', '3'),
      (base, upper, lower, digit) => {
        const password = upper + lower + digit + base;
        return isStrongPassword(password) === true;
      }
    )
  );
});

Обратите внимание: мы не просто генерируем случайные строки. Мы контролируем, чтобы в пароле точно были заглавная, строчная буква и цифра, а длина была большой. Это называется «направленная генерация

Блоги

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