Изучите тестирование на основе свойств в JavaScript с fast-check. Это руководство поможет вам находить больше багов, написав меньше тестов, выйдя за рамки традиционных подходов.
За рамками примеров: Глубокое погружение в тестирование на основе свойств в JavaScript
Как разработчики программного обеспечения, мы тратим значительное количество времени на написание тестов. Мы тщательно создаем модульные, интеграционные и сквозные тесты, чтобы убедиться, что наши приложения надежны, стабильны и не содержат регрессий. Доминирующей парадигмой в этом является тестирование на основе примеров. Мы придумываем конкретный ввод и утверждаем конкретный вывод. Ввод `[1, 2, 3]` должен дать вывод `6`. Ввод `"hello"` должен стать `"HELLO"`. Но у этого подхода есть тихая, скрытая слабость: наше собственное воображение.
Что, если вы забудете протестировать с пустым массивом? Отрицательным числом? Строкой, содержащей символы Unicode? Глубоко вложенным объектом? Каждый пропущенный крайний случай — это потенциальная ошибка, которая ждет своего часа. Именно здесь на сцену выходит тестирование на основе свойств (Property-Based Testing, PBT), предлагая мощный сдвиг парадигмы, который помогает нам создавать более уверенное и отказоустойчивое программное обеспечение.
Это исчерпывающее руководство проведет вас по миру тестирования на основе свойств в JavaScript. Мы рассмотрим, что это такое, почему оно так эффективно и как вы можете внедрить его в свои проекты уже сегодня, используя популярную библиотеку `fast-check`.
Ограничения традиционного тестирования на основе примеров
Рассмотрим простую функцию, которая сортирует массив чисел. Используя популярный фреймворк, такой как Jest или Vitest, наш тест может выглядеть так:
// A simple (and slightly naive) sort function
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// A typical example-based test
test('sortNumbers should correctly sort a simple array', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Этот тест проходит. Мы можем добавить еще несколько блоков `it` или `test`:
- Массив, который уже отсортирован.
- Массив с отрицательными числами.
- Массив с нулем.
- Пустой массив.
- Массив с дублирующимися числами (что мы уже покрыли).
Мы довольны. Мы покрыли основы. Но что мы упустили? Как насчет `[-0, 0]`? А `[Infinity, -Infinity]`? А как насчет очень большого массива, который может столкнуться с ограничениями производительности или странными оптимизациями движка JavaScript? Основная проблема заключается в том, что мы вручную выбираем данные. Наши тесты хороши лишь настолько, насколько хороши примеры, которые мы можем придумать, а люди, как известно, плохо представляют все странные и удивительные способы структурирования данных.
Тестирование на основе примеров подтверждает, что ваш код работает для нескольких подобранных вручную сценариев. Тестирование на основе свойств подтверждает, что ваш код работает для целых классов входных данных.
Что такое тестирование на основе свойств? Смена парадигмы
Тестирование на основе свойств переворачивает сценарий. Вместо того чтобы утверждать, что конкретный ввод дает конкретный вывод, вы определяете общее свойство вашего кода, которое должно оставаться истинным для любого корректного ввода. Затем фреймворк для тестирования генерирует сотни или тысячи случайных входных данных, чтобы попытаться опровергнуть ваше свойство.
«Свойство» — это инвариант, высокоуровневое правило о поведении вашей функции. Для нашей функции `sortNumbers` некоторые свойства могут быть такими:
- Идемпотентность: Сортировка уже отсортированного массива не должна его изменять. `sortNumbers(sortNumbers(arr))` должно быть таким же, как `sortNumbers(arr)`.
- Инвариантность длины: Отсортированный массив должен иметь ту же длину, что и исходный массив.
- Инвариантность содержимого: Отсортированный массив должен содержать точно те же элементы, что и исходный, просто в другом порядке.
- Порядок: Для любых двух соседних элементов в отсортированном массиве `sorted[i] <= sorted[i+1]`.
Этот подход заставляет вас перейти от размышлений об отдельных примерах к размышлениям о фундаментальном контракте вашего кода. Этот сдвиг в мышлении невероятно ценен для проектирования более качественных и предсказуемых API.
Ключевые компоненты PBT
Фреймворк для тестирования на основе свойств обычно имеет два ключевых компонента:
- Генераторы (или Arbitraries): Они отвечают за создание широкого спектра случайных данных в соответствии с указанными типами (целые числа, строки, массивы объектов и т. д.). Они достаточно умны, чтобы генерировать не только данные для «счастливого пути», но и сложные крайние случаи, такие как пустые строки, `NaN`, `Infinity` и другие.
- Shrinking (сужение): Это волшебный ингредиент. Когда фреймворк находит входные данные, которые опровергают ваше свойство (т. е. вызывают сбой теста), он не просто сообщает о больших, случайных входных данных. Вместо этого он систематически пытается найти самый маленький и простой ввод, который все еще вызывает сбой. Это делает отладку экспоненциально проще.
Начало работы: Внедрение PBT с помощью `fast-check`
Хотя в экосистеме JavaScript существует несколько библиотек для PBT, `fast-check` — это зрелый, мощный и хорошо поддерживаемый выбор. Он без проблем интегрируется с популярными фреймворками для тестирования, такими как Jest, Vitest, Mocha и Jasmine.
Установка и настройка
Сначала добавьте `fast-check` в dev-зависимости вашего проекта. Мы будем предполагать, что вы используете средство запуска тестов, такое как Jest.
npm install --save-dev fast-check jest
# or
yarn add --dev fast-check jest
# or
pnpm add -D fast-check jest
Ваш первый тест на основе свойств
Давайте перепишем наш тест для `sortNumbers`, используя `fast-check`. Мы будем проверять свойство «порядка», которое мы определили ранее: каждый элемент должен быть меньше или равен следующему за ним.
import * as fc from 'fast-check';
// The same function from before
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('the output of sortNumbers should be a sorted array', () => {
// 1. Describe the property
fc.assert(
// 2. Define the arbitraries (input generators)
fc.property(fc.array(fc.integer()), (data) => {
// `data` is a randomly generated array of integers
const sorted = sortNumbers(data);
// 3. Define the predicate (the property to check)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // The property is falsified
}
}
return true; // The property holds for this input
})
);
});
test('sorting should not change the array length', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Давайте разберем это:
- `fc.assert()`: Это средство запуска. Оно выполнит вашу проверку свойства много раз (по умолчанию 100).
- `fc.property()`: Это определяет само свойство. Оно принимает один или несколько arbitraries в качестве аргументов, за которыми следует функция-предикат.
- `fc.array(fc.integer())`: Это наш arbitrary. Он говорит `fast-check` генерировать массив (`fc.array`) целых чисел (`fc.integer()`). `fast-check` автоматически сгенерирует массивы разной длины, с разными целыми числами (положительными, отрицательными, нулем и т.д.).
- Предикат: Анонимная функция `(data) => { ... }` — это место, где живет наша логика. Она получает случайно сгенерированные данные и должна возвращать `true`, если свойство выполняется, или `false`, если оно нарушено. `fast-check` также поддерживает функции-предикаты, которые выбрасывают ошибку при сбое, что хорошо интегрируется с утверждениями `expect` из Jest.
Теперь вместо одного теста с одним вручную подобранным массивом у нас есть тест, который проверяет нашу логику сортировки на 100 различных, автоматически сгенерированных массивах при каждом запуске нашего набора тестов. Мы значительно увеличили наше тестовое покрытие всего несколькими строками кода.
Изучение Arbitraries: Генерация правильных данных
Сила PBT заключается в его способности генерировать разнообразные и сложные данные. `fast-check` предоставляет богатый набор arbitraries для покрытия почти любой структуры данных, которую вы можете себе представить.
Базовые Arbitraries
Это строительные блоки для генерации ваших данных.
- `fc.integer()`, `fc.float()`, `fc.bigInt()`: Для чисел. Их можно ограничивать, например, `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()`: Для строк с различными наборами символов.
- `fc.boolean()`: Для `true` или `false`.
- `fc.constant(value)`: Всегда возвращает одно и то же значение. Полезно для смешивания с `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)`: Возвращает одно из предоставленных постоянных значений.
Сложные и составные Arbitraries
Вы можете комбинировать базовые arbitraries для создания сложных структур данных.
- `fc.array(arbitrary, constraints)`: Генерирует массив элементов, созданных предоставленным arbitrary. Вы можете ограничить `minLength` и `maxLength`.
- `fc.tuple(arb1, arb2, ...)`: Генерирует массив фиксированной длины, где каждый элемент имеет определенный, разный тип.
- `fc.object(shape)`: Генерирует объекты с определенной структурой. Пример: `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)`: Генерирует значение из любого из предоставленных arbitraries. Это отлично подходит для тестирования функций, которые обрабатывают несколько типов данных (например, `string | number`).
- `fc.record({ key: arb, value: arb })`: Генерирует объекты для использования в качестве словарей или карт, где ключи и значения генерируются из arbitraries.
Создание пользовательских Arbitraries с помощью `map` и `chain`
Иногда вам нужны данные, которые не соответствуют стандартной форме. `fast-check` позволяет вам создавать свои собственные arbitraries, преобразуя существующие.
Использование `.map()`
Метод `.map()` преобразует вывод arbitrary во что-то другое. Например, давайте создадим arbitrary, который генерирует непустые строки.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Or, by transforming an array of characters
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Использование `.chain()`
Метод `.chain()` более мощный. Он позволяет создавать новый arbitrary на основе сгенерированного значения предыдущего. Это необходимо для создания взаимосвязанных данных.
Представьте, что вам нужно сгенерировать массив, а затем действительный индекс для этого же массива. Вы не можете сделать это с помощью двух отдельных arbitraries, так как индекс может оказаться за пределами допустимого диапазона. `.chain()` решает эту проблему идеально.
// Generate an array and a valid index into it
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Based on the generated array `arr`, create a new arbitrary for the index
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Return a tuple of the array and the generated index
return fc.tuple(fc.constant(arr), indexArb);
});
// Usage in a test
test('slicing at a valid index should work', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// Both `arr` and `index` are guaranteed to be compatible
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
Сила Shrinking: Отладка стала проще
Самая убедительная особенность тестирования на основе свойств — это shrinking (сужение). Чтобы увидеть это в действии, давайте создадим намеренно ошибочную функцию.
// This function fails if the input array contains the number 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('This number is not allowed!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug should sum numbers', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Когда вы запустите этот тест, `fast-check` почти наверняка найдет случай, приводящий к сбою. Но он не сообщит о первом случайном массиве, который он нашел, который мог бы быть чем-то вроде `[-1024, 500, 42, 987, -2000]`. Такой отчет о сбое не очень полезен. Вам пришлось бы вручную проверять его, чтобы найти проблемное число `42`.
Вместо этого включится shrinker из `fast-check`. Он увидит сбой и начнет упрощать входные данные:
- Могу ли я удалить элемент? Попробуем `[500, 42, 987, -2000]`. Все еще падает. Хорошо.
- Могу ли я удалить еще один? Попробуем `[42, 987, -2000]`. Все еще падает.
- ...и так далее, пока он не сможет удалить больше элементов, не сделав тест проходящим.
- Он также попытается уменьшить числа. Может ли `42` быть `0`? Нет, тест проходит. А `41`? Тест проходит. Он сужает поиск.
Окончательный отчет об ошибке будет выглядеть примерно так:
Error: Property failed after 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Counterexample: [[42]]
Shrunk 5 time(s)
Got error: This number is not allowed!
Он сообщает вам точный, минимальный ввод, который вызвал сбой: массив, содержащий только число `[42]`. Это немедленно указывает вам на источник ошибки, экономя огромное количество времени и усилий на отладку.
Практические стратегии PBT и реальные примеры
PBT подходит не только для математических функций. Это универсальный инструмент, который можно применять во многих областях разработки программного обеспечения.
Свойство: Обратные функции
Если у вас есть функция, которая кодирует данные, и другая, которая их декодирует, они являются обратными друг другу. Отличное свойство для проверки: декодирование закодированного значения всегда должно возвращать исходное значение.
// `encode` and `decode` could be for base64, URI components, or custom serialization
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) should be equal to x', () => {
// `fc.jsonValue()` generates any valid JSON value: strings, numbers, objects, arrays
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Свойство: Идемпотентность
Операция является идемпотентной, если ее многократное применение имеет тот же эффект, что и однократное применение. `f(f(x)) === f(x)`. Это критически важное свойство для таких вещей, как функции очистки данных или эндпоинты `DELETE` в REST API.
// A function that removes leading/trailing whitespace and collapses multiple spaces
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace should be idempotent', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Свойство: Тестирование на основе состояний (модельное)
Это более продвинутая, но невероятно мощная техника для тестирования систем с внутренним состоянием, таких как UI-компонент, корзина покупок или конечный автомат. Идея заключается в создании простой программной модели вашей системы и серии команд, которые можно выполнить как на вашей модели, так и на реальной реализации. Свойство заключается в том, что состояние модели и состояние реальной системы всегда должны совпадать.
`fast-check` предоставляет `fc.commands` для этой цели. Давайте смоделируем простой счетчик:
// The real implementation
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// The commands for fast-check
const incrementCmd = fc.command(
// check: a function to check if the command can be run on the model
(model) => true,
// run: a function to execute the command on both model and real system
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter should behave according to the model', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
В этом тесте `fast-check` сгенерирует случайную последовательность команд `increment` и `decrement`, выполнит их как на нашей простой объектной модели, так и на реальном классе `Counter`, и убедится, что они никогда не расходятся. Это может выявить тонкие ошибки в сложной логике состояний, которые было бы почти невозможно найти с помощью тестирования на основе примеров.
Когда НЕ следует использовать тестирование на основе свойств
PBT — это мощное дополнение к вашему набору инструментов для тестирования, но оно не заменяет все другие формы тестирования. Это не серебряная пуля.
Тестирование на основе примеров часто лучше, когда:
- Тестирование конкретных, известных бизнес-правил. Если расчет налога должен дать ровно `$10.53` для конкретного ввода, простой тест на основе примера будет более ясным и прямым. Это регрессионный тест для известного требования.
- «Свойство» — это просто «ввод X дает вывод Y». Если нет более высокоуровневого, обобщаемого правила о поведении функции, принудительное использование теста на основе свойств может оказаться сложнее, чем того стоит.
- Тестирование пользовательских интерфейсов на визуальную корректность. Хотя вы можете тестировать логику состояния UI-компонента с помощью PBT, проверка конкретной визуальной компоновки или стиля лучше выполняется с помощью snapshot-тестирования или инструментов визуальной регрессии.
Наиболее эффективная стратегия — это гибридный подход. Используйте тесты на основе свойств для стресс-тестирования ваших алгоритмов, преобразований данных и логики состояний в целой вселенной возможностей. Используйте традиционные тесты на основе примеров, чтобы закрепить конкретные, критически важные бизнес-требования и предотвратить регрессии по известным ошибкам.
Заключение: Думайте свойствами, а не только примерами
Тестирование на основе свойств способствует глубокому сдвигу в нашем мышлении о корректности. Оно заставляет нас отойти от отдельных примеров и рассмотреть фундаментальные принципы и контракты, которые наш код должен соблюдать. Делая это, мы можем:
- Обнаруживать неожиданные крайние случаи, для которых мы бы никогда не подумали написать тесты.
- Получать гораздо большую уверенность в надежности нашего кода.
- Писать более выразительные тесты, которые документируют поведение нашей системы, а не просто ее вывод на нескольких входных данных.
- Резко сокращать время отладки благодаря силе shrinking.
Внедрение тестирования на основе свойств может показаться непривычным на первый взгляд, но вложения того стоят. Начните с малого. Выберите чистую функцию в вашей кодовой базе — ту, что обрабатывает преобразование данных или сложные вычисления — и попробуйте определить для нее свойство. Добавьте один тест на основе свойств в ваш следующий проект. Когда вы увидите, как он найдет свою первую нетривиальную ошибку, вы убедитесь в его способности создавать лучшее и более надежное программное обеспечение для глобальной аудитории.
Дополнительные ресурсы
- Официальная документация fast-check
- Понимание тестирования на основе свойств от Скотта Влашина (классическое, не зависящее от языка введение)