Что, если тесты пишут сами себя? Знакомство с 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 — будет.
Вместо того чтобы перечислять примеры, вы описываете инварианты (свойства), которые всегда должны быть истинны:
- Длина отсортированного массива равна длине исходного.
- Каждый элемент исходного массива присутствует в отсортированном.
- Для любого индекса
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;
}
)
);
});
Обратите внимание: мы не просто генерируем случайные строки. Мы контролируем, чтобы в пароле точно были заглавная, строчная буква и цифра, а длина была большой. Это называется «направленная генерация