В последнее время все чаще встречаются упоминания о некоем волшебном средстве — тестировании на основе свойств (property based testing, если надо погуглить англоязычную литературу). Большинство статей на эту тему рассказывают о том, какой это классный подход, затем на элементарном примере показывают как написать такой тест используя какой-то конкретный фреймворк, в лучшем случае подсказывают несколько часто встречающихся свойств, и… на этом все заканчивается. Дальше изумленный и воодушевленный читатель пытается применить все это на практике, и упирается в то, что свойства как-то не придумываются. И к большому сожалению часто на этом сдается. В этой статье я постараюсь расставить приоритеты немного по другому. Начну все-таки с более-менее конкретного примера, чтобы объяснить что это за зверь такой. Но пример, надеюсь, не совсем типичный для подобного рода статей. Затем попробую разобрать некоторые проблемы, связанные с этим подходом, и как их можно решить. А вот дальше — свойства, свойства и только свойства, с примерами куда их можно приткнуть. Интересно?

Тестируем хранилище ключ-значение в три коротких теста


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

  • записать значение по ключу
  • проверить, существует ли запись с нужным ключом
  • прочитать значение по ключу
  • получить список записанных элементов
  • получить копию хранилища

При классическом подходе на основе примеров типичный тест выглядел бы как-то так:

storage = Storage()
storage['a'] = 42
assert len(storage) == 1
assert 'a' in storage
assert storage['a'] == 42

Или так:

storage = Storage()
storage['a'] = 42
storage['b'] = 73
assert len(storage) == 2
assert 'a' in storage
assert 'b' in storage
assert storage['a'] == 42
assert storage['b'] == 73

И в общем-то подобных тестов можно и нужно будет написать немного больше, чем дофига. Причем чем сложнее внутренняя реализация, тем больше шансов все равно что-то упустить. Короче, долгая, нудная и часто неблагодарная работа. Как хорошо было бы ее на кого-то спихнуть! Например, заставить компьютер генерировать тест-кейсы за нас. Для начала попробуем сделать что-то такое:

storage = Storage()
key = arbitrary_key()
value = arbitrary_value()
storage[key] = value
assert len(storage) == 1
assert key in storage
assert storage[key] == value 

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

storage = arbitrary_storage()
storage_copy = storage.copy()
assert len(storage) == len(storage_copy)
assert all(storage_copy[key] == storage[key] for key in storage)
assert all(storage[key] == storage_copy[key] for key in storage_copy)

Здесь вместо того, чтобы взять пустое хранилище мы генерируем произвольное с некими данными, и проверяем, что его копия идентична оригиналу. Да, генератор надо еще написать, используя потенциально глючное публичное API, но как правило это не такая сложная задача. Заодно если в реализации есть какие-то серьезные баги, то высоки шансы, что начнутся падения в процессе генерации, так что это можно считать еще и своеобразным бонусным smoke-тестом. Зато теперь мы можем быть уверены — все, что смог нагенерить генератор может быть корректно скопировано. А благодаря первому тесту мы точно знаем, что генератор может создать хранилище хотя бы с одним элементом. Время для следующего теста! Заодно повторно используем генератор:

storage = arbitrary_storage()
backup = storage.copy()
key = arbitrary_key()
value = arbitrary_value()
if key in storage:
	return
storage[key] = value
assert len(storage) == len(backup) + 1
assert key in storage
assert storage[key] == value
assert all(storage[key] == backup[key] for key in backup)

Берем произвольное хранилище, и проверяем, что можем туда добавить еще один элемент. А значит генератор может создать хранилище с двумя элементами. И в него тоже можно добавить элемент. И так далее (сразу вспоминается такая штука, как математическая индукция). В результате написанные три теста и генератор позволяют достаточно надежно проверить, что в хранилище можно добавить произвольное число разных элементов. Всего три коротких теста! Вот в общем-то и вся идея тестов на основе свойств:

  • находим свойства
  • проверяем свойства на куче разных данных
  • профит!

Кстати, этот подход никак не противоречит принципам TDD — тесты точно так же можно писать до кода (по крайней мере лично я обычно так и делаю). Другое дело, что заставить такой тест стать зеленым может оказаться сильно сложнее, чем традиционный, но зато когда он наконец успешно пройдет мы будем уверены, что код действительно соблюдает определенную часть контракта.

Это все конечно хорошо, но...


При всей привлекательности подхода тестирования на основе свойств есть целый ворох проблем. В этой части я постараюсь разобрать наиболее часто встречающиеся. И если не считать проблемы с собственно сложностью поиска полезных свойств (к которой я вернусь в следующем разделе), то на мой взгляд самая большая беда начинающих — это часто ложная уверенность в хорошем покрытии. Действительно, мы написали несколько тестов, которые генерируют сотни тест-кейсов — что может пойти не так? Если посмотреть на пример из предыдущей части, то на самом деле много чего. Для начала, написанные тесты не дают никакой гарантии, что storage.copy() действительно сделает «глубокую» копию, а не просто скопирует указатель. Другая дыра — нет нормальной проверки, что key in storage вернет False если искомого ключа нет в хранилище. И список можно еще продолжать. Ну и один из моих любимых примеров — допустим, мы пишем сортировку, и по какой-то причине считаем, что одного теста, проверяющего порядок элементов достаточно:

input = arbitrary_list()
output = sort(input)
assert all(a <= b for a, b in zip(output, output[1:]))

И вот такая реализация его отлично пройдет

def sort(input):
	return [1, 2, 3]

Надеюсь, мораль тут ясна.

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

Теперь к вопросу о фреймворках, что от них ждать и зачем они вообще нужны — ведь никто не запрещает руками в цикле гонять тест, вызывая внутри рандом и радуясь жизни. На самом деле, радость будет до первого падения теста, и хорошо если локально, а не в каком-нибудь CI. Во-первых, поскольку тесты на основе свойств рандомизированы, то обязательно нужен способ надежно воспроизвести упавший кейс, и любой уважающий себя фреймворк позволяет это делать. Наиболее популярные подходы — выводить в консоль некий seed, который можно вручную подсунуть в test runner и надежно воспроизвести упавший кейс (удобно для отладки), либо создавать на диске кэш с «плохими» сидами, которые будут при запуске теста автоматически проверяться в первую очередь (помогает с повторяемостью в CI). Другой важный аспект — это минификация данных (shrinking в зарубежных источниках). Поскольку данные генерируются рандомно, то есть совершенно неиллюзорный шанс попасть на падающий тест-кейс с контейнером из 1000 элементов, который отлажить то еще «удовольствие». Поэтому хорошие фреймворки после того как нашли фейлящийся кейс применяют ряд эвристик, чтобы попытаться найти более компактный набор входных данных, которые тем не менее будут продолжать валить тест. Ну и наконец — часто половина функционала теста — это генератор входных данных, поэтому наличие встроенных генераторов и примитивов, позволяющих быстро собирать из простых генераторов более сложные также сильно помогают.

Также периодически встречается критика, что тестах на основе свойств слишком много логики. Однако обычно это сопровождается примерами в стиле

data = totally_arbitrary_data()
perform_actions(sut, data)
if is_category_a(data):
	assert property_a_holds(sut)
else if is is_category_b(data):
	assert property_b_holds(sut)

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

data = totally_arbitrary_data()
assume(is_category_a(data))
perform_actions(sut, data)
assert property_a_holds(sut)

и

data = data_from_category_b()
perform_actions(sut, data)
assert property_b_holds(sut)

Полезные свойства, и места их обитания


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

Хотя бы не падай


Самый простой вариант — пихаем произвольные данные в тестируемую систему и проверяем, что она не падает. На самом деле это целое отдельное направление с модным названием фаззинг (fuzzing), для которого существуют специализированные инструменты (например AFL aka American Fuzzy Lop), но с некоторой натяжкой это можно считать частным случаем тестирования на основе свойств, и если вообще никаких идей в голову не лезет, то можно начать с него. Тем не менее, как правило в явном виде такие тесты редко имеют смысл, поскольку потенциальные падения обычно очень неплохо вылазят и при проверке других свойств. Основные причины, почему упоминаю это «свойство» — навести читателя на фаззеры и в частности AFL (англоязычных статей на эту тему полно), ну и для полноты картины.

Тестовый оракул


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

input = arbitrary_list()
assert quick_sort(input) == bubble_sort(input)

Однако этим применимость данного свойства не ограничивается. Например, очень часто оказывается, что функционал реализуемый системой которую мы хотим протестировать является надмножеством чего-то уже реализованного, часто даже в стандартной библиотеке языка. В частности, обычно бОльшую часть функционала какого-нибудь key-value хранилища (в памяти или на диске, на основе деревьев, хэш-таблиц или каких-то более экзотических структур данных типа merkle patricia tree) можно протестировать обычным стандартным словарем. Тестирование всяких CRUDов — туда же.

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

Требования и инварианты


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

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

  • поле класса должно иметь ранее присвоенное значение (геттеры-сеттеры)
  • в хранилище должна быть возможность прочитать ранее записанный элемент
  • добавление ранее несуществующего элемента в хранилище не затрагивает ранее добавленные элементы
  • во многих словарях не могут храниться несколько разных элементов с одинаковым ключом
  • высота сбалансированного дерева должна быть не больше $K \cdot log(N)$, где $N$ — число записанных элементов
  • результатом сортировки является список упорядоченных элементов
  • результат кодирования в base64 должен содержать только символы из алфавита base64
  • алгоритм построения маршрута должен возвращать последовательность допустимых перемещений, которая приведет из точки A в точку B
  • для всех точек построенных изолиний должно выполняться $f(x, y) = const$
  • алгоритм проверки электронной подписи должен возвращать True, если подпись настоящая и False в противном случае
  • в результате ортонормирования все вектора в базисе должны иметь единичную длину и нулевые взаимные скалярные произведения
  • операции переноса и вращения вектора не должны менять его длину

В принципе тут можно было бы сказать что все, статья закончена, используйте тестовые оракулы или ищите свойства в требованиях, но есть еще несколько интересных «частных случаев», которые хотелось бы отметить отдельно.

Индукция и тестирование состояния (stateful testing)


Иногда требуется потестировать нечто имеющее состояния. В этом случае самый простой способ:

  • написать тест, проверяющий корректность начального состояния (например — что только что созданный контейнер пустой)
  • написать генератор, который используя набор случайных операций приведет систему в некое произвольное состояние
  • написать тесты на все операции используя в качестве начального состояния результат работы генератора

Очень похоже на математическую индукцию:

  • доказать утверждение 1
  • доказать утверждение N+1, считая что утверждение N верно

Другой метод (иногда дающий чуть больше информации о том где сломалось) — генерировать допустимую последовательность событий, применять ее к тестируемой системе и проверять свойства после каждого шага.

Туда-сюда-обратно


Если внезапно появилась необходимость протестировать пару функций для прямого и обратного преобразования каких-то данных, то считайте, что вам крупно повезло:

input = arbitrary_data()
assert decode(encode(input)) == input

Отлично подходит для тестирования:

  • сериализации-десериализации
  • шифрования-дешифрования
  • кодирования-декодирвания
  • преобразования базисной матрицы в кватернион и обратно
  • прямое и обратное преобразование координат
  • прямое и обратное преобразование Фурье

Частный, но интересный случай — инверсия:

input = arbitrary_data()
assert invert(invert(input)) == input

Яркий пример — обращение или транспонирование матрицы.

Идемпотентность


Некоторые операции не меняют результат при повторном применении. Типичные примеры:

  • сортировка
  • всякие нормировки векторов и базисов
  • повторное добавение существующего элемента в множество или словарь
  • повторная запись одинаковых данных в какое-то свойство объекта
  • приведение данных к канонической форме (пробелы в JSON привести к единому стилю например)

Также идемпотентность можно использовать для тестирования сериализации-десериализации если обычный способ decode(encode(input)) == input не подходит из-за разных возможных представлений для эквивалентных входных данных (опять же — пробелы лишние в каком-нибудь JSONе):

def normalize(input):
	return decode(encode(input))

input = arbitrary_data()
assert normalize(normalize(input)) == normalize(input)

Разные пути, один результат


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

a = arbitrary_value()
b = arbitrary_value()
assert a + b == b + a

Может показаться тривиальным, но это отличный способ потестировать:

  • сложение и умножение чисел в нестандартном представлении (bigint, rational, вот это все)
  • «сложение» точек на эллиптических кривых в конечных полях (привет, криптография!)
  • объединение множеств (которые внутри могут иметь совсем нетривиальные структуры данных)

Кроме того, этим же свойством обладает и добавление элементов в словарь:

A = dict()
A[key_a] = value_a
A[key_b] = value_b
B = dict()
B[key_b] = value_b
B[key_a] = value_a
assert A == B

Вариант посложнее — долго думал как описать словами, но в голову приходит только математическая запись. В общем, часто встречаются такие преобразования $f(x)$, для которых выполняется свойство $f(x + y) = f(x) \cdot f(y)$, причем как аргумент, так и результат функции — это не обязательно именно число, а операции $+$ и $\cdot$ — просто некоторые бинарные операции над этими объектами. Что можно этим потестировать:

  • сложение и умножение всяких странных чисел, векторов, матриц, кватернионов ($a \cdot (x+y) = a \cdot x + a \cdot y$)
  • линейные операторы, в частности всякие интегралы, дифференциалы, свертки, цифровые фильтры, преобразования Фурье и т.п ($ F[x+y] = F[x] + F[y]$)
  • операции над одинаковыми объектами в разных представлениях, например

    • $M(q_a \cdot q_b) = M(q_a) \cdot M(q_b)$, где $q_a$ и $q_b$ — это единичные кватернионы, а $M(q)$ — операция преобразования кватерниона в эквивалентную базисную матрицу
    • $ F[a \circ b] = F[a] \cdot F[b]$, где $a$ и $b$ — это сигналы, $\circ$ — свертка, $\cdot$ — умножение, а $ F$ — преобразование Фурье


Пример чуть более «обыденной» задачи — потестировать какой-нибудь хитрый алгоритм слияния словарей можно как-то так:

a = arbitrary_list_of_kv_pairs()
b = arbitrary_list_of_kv_pairs()
result = as_dict(a)
result.merge(as_dict(b))
assert result == as_dict(a + b)

Вместо заключения


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


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

Комментарии (44)


  1. babylon
    23.12.2018 09:33
    -1

    Последние времена. Ну это как у кого. Меня эта тема занимает последние лет пять. Тренд в принципе верный, но предлагаемые решения по прежнему сложны. Может так и надо.


    1. Xop Автор
      23.12.2018 14:10
      +1

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


  1. vintage
    23.12.2018 11:33

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


    1. Xop Автор
      23.12.2018 14:16

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


      1. vintage
        23.12.2018 14:40

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


  1. math_coder
    23.12.2018 13:40

    > Короче, долгая, нудная и часто неблагодарная работа.

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


    1. Xop Автор
      23.12.2018 14:38

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


      1. babylon
        24.12.2018 12:18
        +1

        Если смотреть на тестирование как на трейсинг дерева сценариев (фактически контроллер), каждому узлу которого пробрасывается заданный ему набор тестов с соответствующим набором контрольных и граничных значений, то тогда да. Хотя на мой взгляд недалёк тот день когда роль тестировщика возьмут на себя нейросети. У них для этого все есть.


        1. crazy_llama
          24.12.2018 13:10

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


  1. worldmind
    23.12.2018 15:06

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


    1. sshikov
      23.12.2018 16:57

      > в принципе любое тестирование так делается.

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

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


      1. worldmind
        23.12.2018 19:44

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


        1. sshikov
          23.12.2018 20:24

          По-моему, тут нужно четко определить, что такое обычные тесты, и что вы имеете в виду, когда пишете «должны».

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

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

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

          То что вы или я, как автор тестов, должны это сделать — не вопрос. Тут разница в распределении обязанностей между нами и фреймворком.


          1. worldmind
            24.12.2018 14:57

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


            1. sshikov
              24.12.2018 19:37
              +1

              Насколько я понимаю, тесты вообще не дают гарантии отсутствия багов — только если тест упал, у вас есть некая гарантия наличия. Да и то не 100%.

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

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


    1. Xop Автор
      23.12.2018 21:56

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

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


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

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


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

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


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


  1. Druu
    23.12.2018 15:23

    А что, разве любой тест не должен проверять какое-либо свойство? Зачем он нужен тогда, если не проверяет?


    1. Xop Автор
      23.12.2018 17:22

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


      1. Druu
        23.12.2018 18:39
        +1

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

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


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

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


        1. worldmind
          23.12.2018 19:46

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


        1. sshikov
          23.12.2018 20:34

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

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


          1. Druu
            24.12.2018 07:02

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

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


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

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


            1. Xop Автор
              24.12.2018 10:35

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

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


              1. Druu
                24.12.2018 10:42

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

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


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

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


                1. Xop Автор
                  25.12.2018 00:54

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

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


                  1. Druu
                    25.12.2018 05:28

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


                    1. Xop Автор
                      25.12.2018 11:48

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


                      1. Druu
                        25.12.2018 12:20

                        Вырастает.

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


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

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


            1. sshikov
              24.12.2018 19:31
              +1

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

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

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


              1. Druu
                25.12.2018 05:41

                Если у вас на входе enum

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


        1. Xop Автор
          23.12.2018 22:15

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

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


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

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


          1. Druu
            24.12.2018 07:50

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

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


            1. Xop Автор
              25.12.2018 00:49

              Легко.


              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).



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


  1. sshikov
    23.12.2018 16:53

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


    1. Xop Автор
      23.12.2018 17:24

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


      1. sshikov
        23.12.2018 17:53

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

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

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


        1. Xop Автор
          23.12.2018 22:24

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


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

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


  1. Arqwer
    23.12.2018 18:10

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


    1. Druu
      23.12.2018 18:45
      +1

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


      1. sshikov
        23.12.2018 20:25

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


        1. Druu
          24.12.2018 07:20

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


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


          1. Xop Автор
            24.12.2018 10:41

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

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


            1. Druu
              24.12.2018 10:48

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


      1. Xop Автор
        23.12.2018 22:24

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