awesome-everything EN
↑ Обратно к восхождению

Инженерная практика

Property-based-тестирование: инварианты вместо примеров, со сжатием контрпримера

Суть Тесты-примеры проверяют лишь случаи, о которых вы подумали, поэтому слепые зоны набора — это ваши слепые зоны. Property-тест утверждает инвариант над сгенерированным входом — round-trip, оракул, метаморфический — и сжимает падение до минимального контрпримера.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 17 min

У платёжного сервиса 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-tripdecode(encode(x)) === xJSON сериализация/разбор, сжатие, сохранение/загрузка, формат денег
Инвариантфакт, который всегда держится после операции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. 1 Сформулировать инвариант: parse(format(x)) === x для любой дроби x
  2. 2 Фреймворк генерирует сотни дробей, включая граничные значения
  3. 3 Сгенерированное значение, оканчивающееся на .005, нарушает свойство
  4. 4 Сжатие редуцирует его до минимального падающего значения, например 0.005
  5. 5 Зафиксировать напечатанный seed, детерминированно переиграть и починить округление
Вспомните перед уходом
  1. 01
    У нас 94% покрытия. Обоснуйте добавление property-тестов всё равно.
  2. 02
    Пройдите по тому, как property-тест полезно сообщает о падении, от генерации до воспроизводимой починки.
Итог

Тесты на примерах отображают один фиксированный вход в один ожидаемый выход, поэтому вы тестируете только случаи, которые можете вообразить — а значит, слепые зоны набора — это в точности ваши собственные, как форматтер денег оставался зелёным восемь месяцев, потому что каждый пример был круглым числом, а расхождение на .005 жило в зазоре между ними. Property-based-тестирование переворачивает это: вы задаёте инвариант, истинный для всех допустимых входов, и фреймворк генерирует их сотни — пустые, огромные, с обилием дубликатов, граничные — чтобы его атаковать, при fast-check на 100 прогонах по умолчанию и Hypothesis и QuickCheck, делающих то же в разных языках. Четыре формы, которые стоит знать, — это round-trip (decode∘encode — это тождество, самая ходовая и та, что ловит денежный баг), инвариант (факт, всегда истинный после операции), оракул (доверенный эталон согласуется, идеален для переписываний) и метаморфический (отношение между связанными прогонами). Сжатие контрпримера — вот что делает падение отлаживаемым: фреймворк редуцирует падающий массив из 40 элементов до [23, 22], а напечатанный seed переигрывает точный прогон. Цена реальна — свойства труднее писать, они медленнее и флакают, если просочилась нечистота — поэтому дисциплина это чистота, генерация под управлением фреймворка и фиксация seed, когда появился флак.

Связанные уроки
Продолжить восхождение ↑Когда TDD окупается, а когда активно вредит
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.