Как стать автором
Обновить

Как перестать беспокоиться и начать писать тесты на основе свойств

Время на прочтение 11 мин
Количество просмотров 13K
Всего голосов 20: ↑20 и ↓0 +20
Комментарии 49

Комментарии 49

НЛО прилетело и опубликовало эту надпись здесь

Поделитесь опытом?

На подумать: сам тестируемый объект может быть таким «свойством». Например, когда вы создали субкласс — неплохо было бы прогнать на нём тесты суперкласса, чтобы убедиться, что при наследовании ничего не сломалось. Или пример с сортировкой — надо проверять не то, что новая структура ведёт себя так же как уже существующая, копипастя 100500 тестов, а то, что новая структура проходит те же тесты, что и существующая, написав всего одну строчку кода.

Использовать общий набор тестов для разных реализаций одного интерфейса — я как-то думал, что все адекватные люди так и делают, независимо от самих методик тестирования. Или у меня слишком позитивный взгляд на вещи?

Вы слишком хорошего мнения об индустрии :-) Обычно пишут лишь модульные тесты, которые завязаны на конкретную реализацию, которую и тестируют. Тесты на соответствие контракту обычно если и делаются, то в рамках e2e тестов, которые если и пишутся, то по остаточному принципу.
> Короче, долгая, нудная и часто неблагодарная работа.

И предлагается сделать её ещё более нудной? (По-моему, тест и вообще код, содержащий конкретные значения вроде `2`, `'a'`, `«Dr. Jones»` наконец, гораздо менее нудный, чем не содержащий таковых.)

Эмм, ну вот у вас есть интерфейс какого-то хранилища с ключами-строками. Вы пишете один тест, в котором в качестве ключа передаете 'John'. Потом еще один — где ключ пустая строка. Потом еще — где ключ строка максимально допустимой длины. Потом — где ключ максимально допустимой длины, и имеет юникодные символы. Потом — где ключ это невалидная юникодная строка (ну нужно же проверить, что оно там аккуратно ошибку вернет например, а не расхреначит все внутри). Потом начнутся тесты, где вы в разной последовательности добавляете и удаляете элементы. И вот уже у вас штук 30 тупых однообразных тестов, а потом в проде все неожиданно падает, потому что во внутренней реализации была тупая off-by-one ошибка, которую ваши точечные тесты все равно не поймали. Ну, просто не догадались нужную последовательность действий совершить над хранилищем, чтобы проблема вскрылась. А потом выясняется, что интерфейс этого хранилища надо "чуть-чуть поменять", и вам приходится перефигачивать все несколько десятков тестов. А с подходом на основе свойств — написали штук 5-10 тестов, проверяющих основные части контракта — и пусть оно само пытается придумать кейсы, на котором код сломается. Причем на мой субъективный взгляд писать тесты на основе свойств намного интереснее, чем традиционные. Ну и наконец — несколько традиционных тестов в качестве простых примеров и дополнительной документации никто не отменял.

НЛО прилетело и опубликовало эту надпись здесь
Нейросети не могут сгенерировать оракул или эталонные решения. Так как по сложности
это почти тоже самое, что и написать саму программу. Но в вариациях фаззинга (сгенерить данные, чтобы программа упала) они неплохо себя показывают уже сейчас.
Сам подход вроде стал понятнее — мы формулируем какие свойства есть у тестируемого объекта и проверяем что они действительно есть, но в принципе любое тестирование так делается.
Аналог матиндукции — да, вполне интересная аналогия, что-то в таком подходе есть.
Генерация тестовых данных — может быть полезна, но это, как верно подмечено, больше про fuzzy, или это прямо обязательный атрибут тестирования на основе свойств?
Ну и непонятно насколько тут нужны фреймворки, насколько они повышают удобство написания таких тестов?
> в принципе любое тестирование так делается.

В принципе любое?

Обычный (то есть любой) юнит-тест проверяет, что 2+2=4. Property based testing проверяет например, что a+(b+c)=(a+b)+b для всех a, b, c из определенного множества. В случае же «обычных» фреймворков, проверить все возможные варианты — ваша задача.
Если задача была написать функцию сложения и в требованиях были всяким коммутативности и ассоциативности, то обычные тесты это должны проверять.
По-моему, тут нужно четко определить, что такое обычные тесты, и что вы имеете в виду, когда пишете «должны».

Я называю обычными на сегодня что-то типа xUnit, где типичный assert проверяет, что:

(2+3)+4==2+(3+4)

Но почему он вдруг должен делать это для всех возможных значений?

То что вы или я, как автор тестов, должны это сделать — не вопрос. Тут разница в распределении обязанностей между нами и фреймворком.
Ну в данном случае возможных значений бесконечное количество, поэтому в обычных тестах проверяют несколько сильно отличающихся значений, какие-то граничные варианты.
И вопрос даёт ли что-то тестирование большим числом значений которые принципиально не отличаются?
Насколько я понимаю, тесты вообще не дают гарантии отсутствия багов — только если тест упал, у вас есть некая гарантия наличия. Да и то не 100%.

С другой стороны, практикуют же например такие методики, как модификация кода случайным образом, в итоге чего тест обязан упасть. Если он не упал — у нас плохое покрытие. Ищет ли такой способ баги? Вряд ли. Бесполезен ли он в итоге? Не факт.

>принципиально не отличаются?
Те фреймворки, что я видел, позволяют писать свои генераторы. Поэтому в общем случае я бы не сказал, что исходные данные никогда принципиально не отличаются от выбранных вручную.
Генерация тестовых данных — может быть полезна, но это, как верно подмечено, больше про fuzzy, или это прямо обязательный атрибут тестирования на основе свойств?

Видимо я сделал недостаточный акцент на этом в статье, но генерация тестовых данных — это как раз один из основных атрибутов тестов на основе свойств. А fuzzing — это просто очень частный случай, когда тестируемым свойством является "не падать".


Ну и непонятно насколько тут нужны фреймворки, насколько они повышают удобство написания таких тестов?

Можно и без фреймворков, но после написания пары десятков тестов очень большой шанс, что такой фреймворк у вас получится сам собой, если только вы не любите код в стиле copy paste. Правда в отличие от готового скорее всего в нем будет намного меньше фич и гораздо больше косяков. Чем конкретно полезны:


  • не надо в явном виде писать циклы по прогону одного и того же теста
  • удобный инструментарий для быстрого написания генераторов
  • возможность делать повторяемые тесты (например за счет вывода в консоль сида, с которым сгенерился фейлящийсе тест, и который можно подставить в качестве параметра в test runner, чтобы гарантированно получить тот же результат)
  • минификация данных (вы же не хотите дебажить почему ваша функция упала на обработке массива из 1000 элементов?)

Кстати, в статье есть целый абзац, посвященный этому.


Если применительно к питону — на хабре была целая серия статей с переводом документации Hypothesis, там все плюсы использования фреймворка описаны очень подробно.

Обычные тесты проверяют какие-то конкретные единичные примеры. Тесты на основе свойств выделяют определенные зависимости и проверяют их на сотнях примеров, и считается, что это даёт достаточно хорошую гарантию, что свойство будет выполняться для любых данных (ну, в рамках заданных предусловием).

Обычные тесты проверяют какие-то конкретные единичные примеры.

Эм… ну я бы не сказал, что это "обычные" тесты, это скорее тесты человека, который профнепригоден. Нормальные тесты как раз свойства и проверяют, просто на конкретных данных, а не на наборе рандомных. Но разница не существенна.


Тесты на основе свойств выделяют определенные зависимости и проверяют их на сотнях примеров

Вот, это кстати не совсем хорошо, т.к. создает иллюзию, что, раз у вас тест на сотне сгенеренных примеров отработал — то это даст больше гарантий. На самом деле нет.
В самой статье хороший пример на счет ф-и сортировки — если вы забыли проверить, что эта ф-я возвращает массив состоящий из тех же элементов, что и тот, который был подан на вход — то миллион тестов, проверяющих упорядоченность результата вам ничем не поможет. С другой стороны, если ваша ф-я на каком-то рандомном массиве возвращает что-то упорядоченное — то чуть менее чем наверняка она и для любого другого массива так же отработает. Проблема же почти всегда в граничных случаях, а их в общем наборе обычно крайне мало и маловероятно, что автогенерация на какой-то из них случайно попадет.

Если я правильно понял, фреймворки должны как раз пихать граничные значения для тех типов которые знают, но это простые типы, в реальности нужны какие-то кастомные объекты и всё надо реализовывать самому, поэтому и вопрос насколько всё это эффективно за пределами системного программирования.
>это скорее тесты человека, который профнепригоден.
Это обычные тесты в том смысле, что традиционные фреймворки типа xUnit ничего другого вам не предоставляют. Все остальное — ваша обязанность. И цель подхода в том, чтобы снять эту задачу с вас, и решить ее автоматически.

> Но разница не существенна
Тут на циферки хорошо бы поглядеть. Но мне не попадались, честно говоря.
И цель подхода в том, чтобы снять эту задачу с вас, и решить ее автоматически.

Какую конкретно задачу? Вы можете ее сформулировать?


Тут на циферки хорошо бы поглядеть. Но мне не попадались, честно говоря.

Подходу-то уже пол века, но распространения он особого не нашел. Если бы профит был — оно бы пошло в массы. Непонятно просто при чем тут тестирование свойств. Любой нормальный тест — тестирует свойства. А то, о чем идет речь — это просто рандомизированное тестирование, просто как оыбчно хипсторы достали дедушкину презентацию и пробежались атозаменой, переделывая старые скучные обои в новые и веселые.

Какую конкретно задачу? Вы можете ее сформулировать?

Задача — придумывать конкретные примеры, на которых тестировать код. Много разных примеров. Правда одновременно с этим ставится другая задача — находить общие свойства, которые можно легко проверить.

Правда одновременно с этим ставится другая задача — находить общие свойства, которые можно легко проверить.

Это задача, которую решает сам программист. Фреймворк ему тут не помогает никак, верно?


Задача — придумывать конкретные примеры, на которых тестировать код. Много разных примеров.

А кто эту задачу поставил и зачем ее надо решать?

А кто эту задачу поставил и зачем ее надо решать?

Тот, кто сказал покрыть код тестами. Или вообще писать по TDD.

Так покрытие от этого не вырастет. И ТДД спокойно работает быз тысяч одинаковых тестов.

Вырастает. Не всегда конечно, но достаточно часто. И тестов таких обычно можно гораздо меньше писать, чем традиционных — что положительно сказывается на стоимости как разработки, так и поддержки. И краевые случаи ловятся такие, про которые далеко не каждый быстро догадается, если догадается вообще, пока в проде не сломается. В общем, похоже нужна отдельная статья туториал про то, как Вася и Петя писали тесты и код...

Вырастает.

Каким таким образом?


И тестов таких обычно можно гораздо меньше писать

Почему? У них же соотношение 1к1. На каждый традиционный тест нужен один "по свойствам" (т.к. традиционные тесты точно так же покрывают свойства).

Не, погодите.

>рандомизированное тестирование
Насколько я помню, изначально не предполагалось тут никакого рандомизированного. По крайней мере — в теории (хотя если открыть мануал по QuickCheck, то да, random будет на первой же странице).

Если у вас на входе enum — перебрать все возможные значения не только можно, но и нужно. А что касается множеств типа целых или действительных чисел — ну да, тут все хуже. Доказывать по индукции типичный фреймворк так же не умеет, как и «обычный» xUnit. Но тем не менее — генераторы значений можно писать свои, и они не обязаны быть число случайными.
Если у вас на входе enum

Ну а зачем вы рассматриваете редкий частный случай? Сколько у вас ф-й, которые принимают исключительно енумы, и сколько — которые принимают строки, числа, объекты более сложной структуры?

Вот, это кстати не совсем хорошо, т.к. создает иллюзию, что, раз у вас тест на сотне сгенеренных примеров отработал — то это даст больше гарантий. На самом деле нет.

Тут на самом деле можно провести аналогию — баги это враги, традиционные тесты — выискивание врагов в бинокль и отстрел из винтовки, тесты на основе свойств — закидывание осколочными гранатами. Можно всех отстрелять по одному из винтовки — но это долго и дорого, плюс всегда есть шанс, что снайпер все равно кого-то упустит. Можно закидать гранатами — всего несколько таких "бросков" может положить просто уйму багов, даже хорошо спрятавшихся, хотя и тут некоторым все равно может повезти. Но тот же процент "зачистки" будет достигнут гораздо меньшими затратами. В целом мое мнение: тесты на основе свойств — не замена традиционным, а очень мощное дополнение. Лучше всего совмещать оба подхода.


Проблема же почти всегда в граничных случаях, а их в общем наборе обычно крайне мало и маловероятно, что автогенерация на какой-то из них случайно попадет.

Да, на какие-то прям совсем неожиданности попадание получается не каждый день, но достаточно часто. И каждое такое попадание в модульном тесте экономит просто кучу ресурсов по сравнению с тем, что бывает когда такое вылазит в проде.

Да, на какие-то прям совсем неожиданности попадание получается не каждый день, но достаточно часто.

А можно несколько примеров?

Легко.


  1. Как-то нужно было реализовать построение полигональной модели по некоторой характеристической функции (а функция в свою очередь строилась через облако точек, но речь сейчас не о нем). Задача решается довольно стандартным способом — marching cubes, но по ряду причин на модель накладывались условия непрерывности (не должно быть дырок) и двусвязности (2-manifold, т.е. одно ребро может граничить не более чем с двумя полигонами). А из-за ошибок точности чисел с плавающей точкой (внезапно при определенных условиях a+(b+c) != (a+b)+c) периодически получались дырки. И тупое округление тоже не помогало — дырки только больше становились. А не совсем тупое округление неожиданно периодически приводило к многосвязным поверхностям. И все это было поймано двумя короткими рандомизированными тестами, так и не добравшись до прода.


  2. В глубине одного pet-проекта надо было посчитать среднее арифметическое между двух целых чисел. Ну и разумеется там было (a + b) / 2. И конечно, при рандомизированном тестировании таки сгенерился кейс, при котором a + b словили целочисленное переполнение, которое не вызывало падения само по себе, но дальше в данных шла дикая ересь. И только благодаря этому тесту я узнал, что целочисленное среднее арифметическое правильно считать как a + (b — a) /2 (при условии, что b > a, иначе меняем местами a и b).



И могу продолжать дальше...

Ну, вообще говоря, quickCheck с 1999 года существует. Так что теме самой уже почти 20 лет.

Да, про quickcheck в хаскеле наслышан, просто до относительно недавнего времени эта тема была совсем далека от мейнстрима.

Ну, это опять же что считать мейнстримом :) Релиз 0.1 scalacheck тоже вышел в 2007 — так что и этому фреймворку уже 11 лет, если смотреть только по github.

Не помню, как давно аналог scalacheck появился в составе functionaljava, но кажется мне отчего-то, что тоже лет 10 назад уже — немногим позже, чем в java появились generics.

Насколько я понимаю, тут зависимость простая — язык должен позволять удобно выражать некоторые вещи. Поэтому в тех языках, где система типов помощнее, это сначала и появляется.

Опять же — какая была популярность у скалы в 2007? :) Я не отрицаю, что подход не новый, но ощущение, что активная популяризация началась примерно года два назад, когда стали появляться статьи про питоновский Hypothesis. Питон простой, на нем пишет дофига людей, он не требует понимания таких "страшных" вещей, как монады и гомоморфизмы — и видимо это стало своеобразным толчком. Но опять же — я могу очень сильно ошибаться.


Поэтому в тех языках, где система типов помощнее, это сначала и появляется.

Кстати, я как-то попытался написать hypothesis-подобный фреймворк на чистой сишечке. И даже стало получаться, но потом резко пропало свободное время примерно на годик. Как думаете — стОит продолжать, или смысла нет?

На чистой сишечке уже есть неплохой фреймворк для тестирования на основе свойств, называется theft.

Про theft я в курсе, и имхо у него есть две серьезные проблемы:


  • для написания любого теста нужна просто куча бойлерплейта
  • минификация реализована отдельно от генерации (в отличие от hypothesis в питоне или proptest в расте)
Тогда стоит продолжить, может у вас получится сделать что-то более удобное, чем theft. Вы исходники пока никуда не выкладывали?
Вообще-то выкладывал, но не уверен, что если буду дальше развивать, то на основе именно этих исходников: github.com/skhoroshavin/qcc

Если немного доработать интерпретатор языка, чтобы он работал с множествами а не экземплярами объекта, то не придётся писать генераторы, а можно будет например просто писать а=множество_чисел_удовлетворяющих_условию_X. И в а будет храниться не что-то конкретное, а само описание множества. Таким ещё Турчин занимался. А если прикрутить к этому хороший синтезатор программ, то он сам напишет код программы, удовлетворяющий тестам. Это активная область исследований сейчас. Так что привыкайте, возможно лет через 10 вам придется писать только тесты вроде этих, а сам код программ будут писать синтезаторы :)

Проблема в том, что сложность написания таких тестов будет эквивалентна сложности написания самого кода. Попробуйте для упражнения рассмотреть пример из статьи с сортировкой и написать исчерпывающий набор тестов на то, что ваша ф-я — стабильная сортировка ;)

Так никто вроде не обещал серебряную пулю… доказать что-то про код (а тут речь идет именно о доказательстве, тем или иным способом) — это все еще сложно.

Здесь проблема не в том, чтобы доказать ("доказывает" как бы сам фреймворк, гоняя случайные данные). Проблема в том, чтобы сформулировать, что требуется доказывать, то есть в том, чтобы написать спецификацию.


Но по факту сам код функции и является полной спецификацией задачи. С-но если задача хорошо ложится на идиомы языка — то любая спецификация по сложности будет эквивалентна коду.

Но по факту сам код функции и является полной спецификацией задачи.

А вот и нет. Например, вы можете очень легко проверить, что функция проверки электронной подписи возвращает True для реальных подписей и False для рандомных данных, но попробуйте такую функцию написать.

Во-первых, вы стрелку перепутали. Я сказал, что код ф-и является полной спецификацией, а не спецификация — кодом.
Во-вторых: "вы можете очень легко проверить" — нет, не могу. И вы не можете. Попробуйте, в качестве тренировки, написать полную спецификацию рассматриваемой вами ф-и, и убедитесь.

Написать исчерпывающий набор тестов только на основе свойств — часто та еще задача. Поэтому идеальный вариант — комбинировать.

падающий тест-кейс с контейнером из 1000 элементов

Можете пояснить, о чём здесь речь? О том, что на вход теста поступила очень большая структура?
Можете привести пример, как должен работать шринкер?

Зарегистрируйтесь на Хабре , чтобы оставить комментарий

Публикации

Истории