Обычный тест проверяет код на примерах, которые придумал человек. Подал на вход тройку, ожидал шестёрку, сверил. Беда в том, что человек проверяет лишь те случаи, до которых додумался, а ошибки чаще всего прячутся ровно там, куда фантазия не дотянулась. Пустая строка, отрицательное число, гигантский массив, строка с управляющим символом посреди. Тестирование на основе свойств переворачивает подход. Вместо отдельных примеров оно формулирует утверждение, которое должно быть истинным для любого входа, а инструмент сам генерирует тысячи разнообразных входов и пытается это утверждение сломать.
Разница принципиальная. Тестирование на примерах покрывает только то, что разработчик сумел вообразить. Тестирование на свойствах вскрывает сценарии, которые он не рассматривал никогда. Девиз метода звучит как замена отдельных примеров утверждениями, и формулируется свойство примерно так: для любых входов, удовлетворяющих предусловию, предикат истинен. Каркас тестирования крутит цикл генерации входов сам и пытается найти такой набор, на котором свойство ложно.
В мире JavaScript и TypeScript этот подход воплощает fast-check. Он написан на TypeScript, даёт строгую типизацию и за годы поймал реальные ошибки в популярных библиотеках вроде разбора YAML, обработки строк запроса и даже печально известного дополнения строк слева. Дальше идёт разбор того, как метод устроен, почему он особенно хорош для парсеров и сериализаторов, и где проходит граница его применимости. Кода ровно столько, сколько нужно увидеть механику, и весь он в отдельных блоках с разбором вокруг.
Свойство вместо примера на классической задаче сортировки
Понять суть проще всего на сортировке. Допустим, есть функция, упорядочивающая массив чисел по возрастанию. На примерах её проверяют так: подал перемешанный массив, сверил с заранее выписанным отсортированным. Это работает, но проверяет ровно один вход. Свойство же формулирует то, что верно для любого массива: результат сортировки всегда упорядочен по возрастанию, то есть каждый следующий элемент не меньше предыдущего.
import fc from 'fast-check'
test('сортировка всегда даёт упорядоченный массив', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (nums) => {
const sorted = sort(nums)
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] < sorted[i - 1]) return false
}
return true
})
)
})
Здесь генератор массивов целых чисел поставляет сотни разных входов: пустые массивы, массивы из одного элемента, с повторами, с отрицательными числами, с экстремальными значениями. Предикат проверяет упорядоченность результата на каждом из них. Если хоть на одном входе порядок нарушен, тест падает и показывает виновный набор данных. В этом и состоит сила метода: пример покрывает случаи, которые мы придумали, а свойство ловит сценарии, о которых мы и не думали.
Стоит честно оговорить ограничение свойства упорядоченности. Сама по себе проверка порядка не ловит потерю или дублирование элементов, ведь функция, возвращающая пустой массив, формально упорядочена. Поэтому свойства часто комбинируют: к порядку добавляют проверку, что результат это перестановка входа той же длины с теми же элементами. Хорошее свойство описывает поведение полно, а не частично.
Сжатие контрпримера до минимума как главное удобство метода
Когда инструмент находит вход, ломающий свойство, начинается самое ценное. Сырой контрпример, найденный случайной генерацией, обычно громоздкий: массив из сотни чисел с шумом, в котором непонятно, что именно вызвало сбой. fast-check автоматически сжимает контрпример до минимального вида, последовательно упрощая его, пока тот ещё ломает свойство. Из массива в сто элементов он выжмет, скажем, два конкретных числа, на которых всё рушится.
Это сжатие, его называют сжатием контрпримера, превращает загадочный сбой в точную улику. Вместо простыни случайных данных разработчик видит минимальный вход, на котором свойство ложно, и причина обычно становится очевидной с первого взгляда. Сообщение о падении показывает и сам сжатый вход, и зерно генератора, чтобы воспроизвести сбой повторно.
Property failed after 12 tests
{ seed: 1527422598337, path: "3:0:1" }
Counterexample: [2, 1]
Shrunk 7 time(s)
Без автоматического сжатия тестирование на свойствах было бы куда менее полезным, потому что разбирать сырые контрпримеры вручную мучительно. Именно сжатие делает метод практичным: инструмент не только находит проблему, но и подаёт её в максимально разжёванном виде. Хорошие каркасы абстрагируют и цикл генерации, и умное сжатие, освобождая человека от рутины.
Почему парсеры и сериализаторы это идеальная мишень для метода
Есть класс кода, где тестирование на свойствах раскрывается особенно ярко, и это парсеры с сериализаторами. Причина в том, что для них существует естественное и мощное свойство, которое почти невозможно проверить вручную на достаточном числе случаев. Свойство называется круговым обходом. Если разобрать строку в структуру, а затем сериализовать структуру обратно, должна получиться исходная строка. Разбор и сборка обязаны быть взаимно обратными.
Сформулировать это как свойство просто и красиво. Для любой допустимой структуры сериализация с последующим разбором возвращает ту же структуру.
test('разбор и сериализация взаимно обратны', () => {
fc.assert(
fc.property(arbitraryParsedUrl(), (parsed) => {
const text = serialize(parsed)
const reparsed = parse(text)
expect(reparsed).toEqual(parsed)
})
)
})
Мощь здесь в том, что одно короткое свойство проверяет согласованность разбора и сборки на тысячах автоматически сгенерированных структур. Вручную выписать столько пар вход-выход немыслимо, а главное, человек всё равно пропустил бы коварные случаи: экранированные символы, пустые поля, специальные значения. Именно на таких случаях парсеры и ломаются чаще всего. Один разработчик описал, как добавил свойство кругового обхода к своему парсеру и тут же обрёл несравнимо большую уверенность в его правильности и в безопасности будущих изменений.
Второе ценное свойство для парсеров это устойчивость к любому входу. Парсер, читающий недоверенные данные, не должен падать ни на какой строке. Свойство формулируется как отсутствие краха разбора на произвольном входе, и генератор скармливает парсеру случайные строки, выискивая ту, что роняет процесс. Сообщество fast-check не раз ловило этим приёмом крахи в парсерах, которые иначе всплыли бы уже в продакшене на злонамеренном вводе.
Производные генераторы и предусловия для сложных структур
Чтобы свойства работали на доменных типах, а не только на числах и строках, нужны генераторы под эти типы. fast-check даёт богатый набор базовых генераторов и удобные способы строить из них сложные. Метод преобразования выводит новый генератор из существующего, применяя функцию к его значениям, и при этом сохраняет способность к сжатию контрпримера, что выгодно отличает инструмент от каркасов, требующих описывать преобразование в обе стороны.
const arbitraryParsedUrl = () =>
fc.record({
protocol: fc.constantFrom('http', 'https'),
host: fc.domain(),
path: fc.webPath(),
query: fc.dictionary(fc.string(), fc.string()),
})
Для случаев, когда не всякий сгенерированный вход допустим, есть механизм предусловий. Он отсеивает невалидные входы прямо внутри проверки, не заставляя писать отдельный генератор. Связывание выхода одного генератора со входом другого делается операцией сцепления, тоже сохраняющей сжатие. А смешивать сгенерированные данные с собственными показательными примерами можно без дублирования кода, добавляя свои случаи к автоматическим. Это удобно, чтобы зафиксировать конкретный исторический баг рядом со случайной генерацией.
Отдельно стоит упомянуть модельный подход, превращающий тестирование на свойствах в инструмент проверки не только чистых функций, но и интерфейсов, программных интерфейсов и конечных автоматов. Инструмент генерирует последовательности действий и сверяет поведение реальной системы с упрощённой моделью, что открывает метод для гораздо более широкого класса задач, чем кажется на первый взгляд.
Где метод оправдан, а где он не лучший выбор
Тестирование на свойствах мощно, но честный практик признаёт, что оно применимо не везде и иногда требует усилий на поиск полезного свойства. Граница проходит по природе кода. Метод раскрывается там, где есть ясное инвариантное свойство, верное для всех входов. Кроме упомянутых парсеров и сериализаторов это алгоритмы с математическими свойствами: сортировки, кодировщики, сжатие, любые преобразования, обладающие обратимостью или сохраняющие некоторый инвариант.
Ещё один сильный сценарий это сравнение двух реализаций. Когда оптимизируют алгоритм, естественное свойство звучит так: старая и новая реализации дают одинаковый результат на любом входе. Свойство равенства старого и нового мгновенно ловит расхождения рефакторинга, которые человек на примерах пропустил бы.
Где метод буксует. Первое это код без ясного инвариантного свойства. Если единственное, что можно сказать о функции, это набор разрозненных конкретных ожиданий, формулировать свойство искусственно вредно, проще остаться на примерах. Второе это код с тяжёлыми побочными эффектами и плохой изоляцией, где каждый прогон дорог, а тысячи генераций превращаются в неприемлемо медленный тест. Третье это ситуация, когда поиск свойства занимает больше сил, чем стоит выгода, что бывает на простой логике с очевидным поведением.
Важнейшая оговорка состоит в том, что тестирование на свойствах дополняет другие стратегии, а не заменяет их. Тот же разработчик, что хвалил свойство кругового обхода, прямо отметил, что всё равно планирует писать обычный набор тестов на примерах для своего парсера. Свойства и примеры работают в паре: примеры фиксируют конкретные важные случаи и читаются как документация, а свойства прочёсывают пространство входов в поисках непредвиденного.
Настройка числа прогонов и баланс между тщательностью и скоростью
Один практический рычаг заслуживает отдельного внимания, потому что от него прямо зависит, станет метод помощником или обузой. По умолчанию инструмент прогоняет свойство на некотором фиксированном числе сгенерированных входов за один запуск. Этого хватает для большинства случаев, но число настраивается, и настройка важна. Чем больше прогонов, тем выше шанс наткнуться на редкий ломающий вход, но тем дольше идёт тест.
fc.assert(
fc.property(fc.array(fc.integer()), (nums) => isSorted(sort(nums))),
{ numRuns: 1000 }
)
Баланс ищут по контексту. На быстром чистом свойстве вроде сортировки не жаль и тысячи прогонов, потому что каждая итерация дёшева. На свойстве, дёргающем тяжёлую операцию, разумнее держать число скромным, иначе тест растянется на минуты и команда начнёт его пропускать. Полезный приём это гонять малое число прогонов на каждом коммите ради скорости и большое число по расписанию ночью, когда время не поджимает, чтобы глубже прочесать пространство входов.
Отдельная тонкость касается воспроизводимости. Поскольку входы генерируются случайно, упавшее свойство важно уметь воспроизвести. Зерно генератора из сообщения о падении передаётся обратно в запуск, и тогда тест повторяет ровно ту же последовательность входов, что привела к сбою. Это превращает случайный по природе метод в детерминированный на этапе отладки, что критично для починки найденной ошибки.
Логирование внутри предиката через контекст прогона помогает понять, что происходило на конкретной итерации, не засоряя вывод на успешных прогонах. Вместе зерно и логирование делают разбор падений предсказуемым, снимая главное возражение против случайной генерации о том, что её трудно отлаживать.
Практические ориентиры внедрения метода в проект
Чтобы свести опыт к рабочим ориентирам, ниже единственный список этой статьи, расставленный по убыванию важности:
- Применять метод там, где есть ясное инвариантное свойство для всех входов, прежде всего на парсерах, сериализаторах и алгоритмах с обратимостью;
- Для парсеров и сериализаторов в первую очередь брать свойство кругового обхода и свойство устойчивости к любому входу, как самые продуктивные;
- Формулировать свойство полно, комбинируя несколько утверждений, чтобы оно не пропускало целые классы ошибок вроде потери элементов;
- Опираться на автоматическое сжатие контрпримера и зерно генератора для воспроизведения, не разбирая сырые входы вручную;
- Строить генераторы доменных типов через преобразование и сцепление, сохраняющие сжатие, а невалидные входы отсекать предусловиями;
- Держать тестирование на свойствах как дополнение к тестам на примерах, а не как их замену, и не выдумывать свойства там, где их нет.
Эти шесть правил отделяют продуктивное применение от насилия над методом. Почти все разочарования в тестировании на свойствах сводятся либо к попытке натянуть его на код без инвариантов, либо к ожиданию, что оно заменит обычные тесты.
Итог укладывается в одну мысль. Тестирование на примерах проверяет код на случаях, которые придумал человек, и потому слепо ровно там, где живут самые коварные ошибки. Тестирование на свойствах формулирует то, что верно всегда, и поручает машине искать опровержение среди тысяч входов, а найдя, сжимает контрпример до минимальной улики. Для парсеров и сериализаторов свойство кругового обхода даёт уверенность, недостижимую ручными примерами, потому что одно утверждение покрывает необозримое пространство случаев. Метод не универсален, требует ясного инварианта и дополняет, а не вытесняет тесты на примерах. Но там, где он применим, он вскрывает то, что разработчик не догадался проверить, и эта способность находить непредвиденное и есть его главная, недооценённая ценность.