Инженерная практика
Property-based-тестирование: инварианты вместо примеров, со сжатием контрпримера
У платёжного сервиса 94% покрытия строк и восемь месяцев зелёного CI. И вот клиента в Турции списывают дважды. Баг: форматтер денег округлял половину-к-чётному на одном пути и половину-вверх на другом, и эти два варианта расходились только на значениях, оканчивающихся на .005. Каждый юнит-тест использовал круглые числа — 19.99, 100.00 — поэтому каждый тест проходил. Никто не написал 0.005, 2.675 или случайно сгенерированную дробь, потому что никто об этом не подумал. Набор был зелёным, и поведение было сломано одновременно, восемь месяцев, потому что примеры и слепые зоны были написаны одним и тем же человеком.
Ваши примеры — это ровно ваши слепые зоны
Тест на примере — это один фиксированный вход, отображённый в один ожидаемый выход: reverse([1,2,3]) === [3,2,1]. Он документирует единственный случай, и вы пишете те случаи, которые можете вообразить — а значит, слепые зоны набора — это в точности ваши слепые зоны. Денежный баг — каноническая форма этого: каждый пример был «красивым» числом, поэтому расхождение половины-к-чётному и половины-вверх на значениях .005 жило нетронутым в зазоре между примерами. Никакое количество дополнительных примеров с круглыми числами его не закроет, потому что зазор задан тем, что вы не догадались набрать.
Property-based-тест переворачивает отношение. Вместо того чтобы задавать пару вход-выход, вы задаёте нечто, что должно быть истинно для всех допустимых входов — инвариант — и фреймворк генерирует сотни входов, чтобы его атаковать. «Двойной разворот списка возвращает исходный список.» «Разбор с последующей сериализацией даёт исходное значение.» Hypothesis (Python), fast-check (JS/TS) и QuickCheck (Haskell, прародитель семейства) кидают в свойство сгенерированные значения: пустые, из одного элемента, огромные, с обилием дубликатов и, что критично, граничные значения, которые сидят там, где живут баги. fast-check по умолчанию делает numRuns: 100 на свойство, настраивается до 1000+ в CI. Вы больше не ограничены входами, которые произвело ваше воображение.
Четыре формы свойств, которые реально ловят баги
Сформулировать полезный инвариант — это акт проектирования, а слабое свойство (результат — это число) тестирует почти ничего, выглядя при этом основательно. Есть четыре формы, которые стоит запомнить, потому что большинство реальных свойств — это экземпляр одной из них. Round-trip: decode(encode(x)) === x — любая пара сериализация/разбор, сжатие/распаковка, сохранение/загрузка. Это самая ходовая форма, и она поймала бы денежный баг за несколько прогонов. Инвариант: факт, который всегда держится после операции — sort(xs).length === xs.length, выход отсортирован, сумма неотрицательна. Оракул: доверенный эталон согласуется с новым кодом — переписанная версия против старой функции, быстрый путь против медленного. Метаморфический: отношение между двумя связанными прогонами — результаты search(q) ⊇ search(q + " AND x").
| Форма свойства | Форма утверждения | Где подходит |
|---|---|---|
| Round-trip | decode(encode(x)) === x | JSON сериализация/разбор, сжатие, сохранение/загрузка, формат денег |
| Инвариант | факт, который всегда держится после операции | sort(xs).length === xs.length; выход отсортирован |
| Оракул | новая реализация согласуется с доверенным эталоном | переписанная vs старая функция; быстрый путь vs медленный |
| Метаморфический | отношение между двумя связанными прогонами | search(q) ⊇ search(q + ” AND x”) |
Сжатие контрпримера — вот почему его можно отлаживать
Причина, по которой property-тестирование пригодно, а не просто шум, — это сжатие контрпримера (shrinking). Когда фреймворк находит падающий вход, он не отдаёт вам гигантское случайное значение, которое случайно сгенерировал; он автоматически ищет наименьший вход, который всё ещё падает, обычно через редукцию в стиле бинарного поиска. fast-check может сгенерировать падающий список из 40 случайных целых, а затем сжать его до [23, 22] — минимальной пары, которая всё ещё нарушает свойство. Hypothesis делает то же и затем прогоняет минимальный пример ещё раз, чтобы убедиться, что падение не флакает, прежде чем сообщить о нём.
Это и есть разница между пригодным баг-репортом и непригодным. Без сжатия вы получаете «упало на [483, -29, 0, 17, -6, 92, ...]» и режете руками; со сжатием вы получаете «упало на [0, -1]», и корневая причина часто очевидна с первого взгляда. Оба фреймворка ещё печатают seed: перезапустите с этим seed — и вы воспроизведёте точно тот падающий случай на своём ноутбуке, что делает в остальном пугающие «случайные тесты» детерминированными для отладки. Генератор находит баг; шринкер делает его дешёвым для понимания; seed делает его воспроизводимым.
Почему это работает
Сеньорское возражение против property-тестов реально: сгенерированная случайность — это риск флакания. Свойство, тайно зависящее от часов, незасеянного Math.random, реального времени или внешнего состояния, может пройти 999 прогонов и упасть на 1000-м, покрасив CI в красный на несвязанном коммите. Дисциплина, которая это обезвреживает, двойная — сделайте свойство чистым и отдайте всю генерацию входа фреймворку, и зафиксируйте seed в тот момент, когда появился флак, чтобы падение было воспроизводимым, а не подбрасыванием монеты. Property-тест, который нельзя воспроизвести по требованию, — это не тест; это слух. Воспроизводимость, а не сырая изобретательность, — вот что делает сгенерированное тестирование заслуживающим доверия в CI.
Вы переписываете проверенный боем, но медленный CSV-парсер, который используется по всей компании. Как протестировать переписанную версию с наибольшим рычагом?
Коллега говорит: «94% покрытия — с чего бы property-тестам найти что-то новое?» Каков точный ответ?
Свойство падает на случайно сгенерированном входе. Почему сжатие контрпримера важно и чем оно отличается от напечатанного seed?
Упорядочьте жизненный цикл property-теста, который ловит денежный баг:
- 1 Сформулировать инвариант: parse(format(x)) === x для любой дроби x
- 2 Фреймворк генерирует сотни дробей, включая граничные значения
- 3 Сгенерированное значение, оканчивающееся на .005, нарушает свойство
- 4 Сжатие редуцирует его до минимального падающего значения, например 0.005
- 5 Зафиксировать напечатанный seed, детерминированно переиграть и починить округление
- 01У нас 94% покрытия. Обоснуйте добавление property-тестов всё равно.
- 02Пройдите по тому, как property-тест полезно сообщает о падении, от генерации до воспроизводимой починки.
Тесты на примерах отображают один фиксированный вход в один ожидаемый выход, поэтому вы тестируете только случаи, которые можете вообразить — а значит, слепые зоны набора — это в точности ваши собственные, как форматтер денег оставался зелёным восемь месяцев, потому что каждый пример был круглым числом, а расхождение на .005 жило в зазоре между ними. Property-based-тестирование переворачивает это: вы задаёте инвариант, истинный для всех допустимых входов, и фреймворк генерирует их сотни — пустые, огромные, с обилием дубликатов, граничные — чтобы его атаковать, при fast-check на 100 прогонах по умолчанию и Hypothesis и QuickCheck, делающих то же в разных языках. Четыре формы, которые стоит знать, — это round-trip (decode∘encode — это тождество, самая ходовая и та, что ловит денежный баг), инвариант (факт, всегда истинный после операции), оракул (доверенный эталон согласуется, идеален для переписываний) и метаморфический (отношение между связанными прогонами). Сжатие контрпримера — вот что делает падение отлаживаемым: фреймворк редуцирует падающий массив из 40 элементов до [23, 22], а напечатанный seed переигрывает точный прогон. Цена реальна — свойства труднее писать, они медленнее и флакают, если просочилась нечистота — поэтому дисциплина это чистота, генерация под управлением фреймворка и фиксация seed, когда появился флак.