Тестирование кода. Hypothesis

Тимур Салимов — Techlead DS

Но зачем?..

Тесты – это дорого, а хорошие тесты – долго и дорого

Важно:

  • Не весь код в целом поддается тестированию.
  • Помимо формы, также важна полнота тест-кейсов.
  • И последнее, но не менее важное, тесты должны быть интегрированы в общую инфраструктуру тестирования.

Property-based testing

Простой пример

Рассмотрим простой пример рассуждение о функции сортировки.

def sort(data):
    sorted_data = sort_alg(data)
    return sorted_data

В случае unit тестирования, мы бы скоре всего начали с того, что предположили бы какие-то простые кейсы, которые должны правильно работать. Например:

assert sort([3,2,1]) == [1,2,3]
assert sort([]) == []

Попробуем обобщить то, что мы точно знаем раз выбрали такие примеры:

  • Функция принимает list[int]
  • Функция возвращает list[int] того же содержания
  • Функция возвращает list[int], в котором каждый следующий элемент больше предыдущего

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

Теперь осталось добавить просто генерацию входных значений и вы получите, в базовом виде, property based тестирование.

Hypothesis

Установка

В случае установки из PyPI стоит обратить внимание на то, что различные интеграции вынесены в модуль extra, который по умолчанию не устанавливается:

pip install hypothesis[numpy,pandas]

При установке из conda channels вы сразу получите все дополнительные интеграции:

conda install -c conda-forge hypothesis

Создание теста

Рассмотрим на примере выше, с функцией сортировки:

def sort(data):
    sorted_data = sort_alg(data)
    return sorted_data

Как будет выглядеть функция для тестирования:

from hypothesis import given
import hypothesis.strategies as st

@given(st.lists(st.integers()))
def test_sort(data):
    sorted_data = sort(data)
    for i in range(len(sorted_data) - 1):
        assert sorted_data[i] <= sorted_data[i+1]

Если мы выполним эту функцию и будет обнаружена ошибка, то вы увидите что-то похожее:

assert sorted_data[i] <= sorted_data[i+1]
assert 1<=0

Falsifying example:
    test_sort(
        data=[1,0,0,0],
    )

Встроенные стратегии

Базовые типы:

  • binary
  • booleans
  • characters
  • complex_numbers
  • decimals
  • floats
  • functions
  • integers
  • none
  • text

Структуры:

  • dictionaries
  • frozensets
  • iterables
  • lists
  • sets
  • slices
  • tuples

Дополнительные:

  • dates
  • datetimes
  • emails
  • ip_addresses
  • timedeltas
  • times
  • timezone_keys
  • timezones

Утилитарные:

  • builds
  • composite
  • data
  • deferred
  • from_regex
  • from_type
  • just
  • one_of
  • permutations
  • recursive
  • sampled_from

Модификации встроенных стратегий

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

  • Map

    >>> lists(integers()).map(sorted).example()
    [-93, -70, -7, 0, 39, 40, 65, 88, 112]
  • Filter

    >>> integers().filter(lambda x: x > 11).example()
    26126
  • Flatmap

    >>> rectangle_lists = integers(min_value=0, max_value=10).flatmap(
            lambda n: lists(lists(integers(), min_size=n, max_size=n))
        )
    
    >>> rectangle_lists.example()
    []
    
    >>> rectangle_lists.filter(lambda t: len(t) >= 3 and len(t[0]) >= 3).example()
    [[0, 1, 0], [2, 1, 0], [7, 9, 1]]

Создание своей стратегии

Для создания собственной стратегии используйте декоратор @composite

@composite
def reimplementing_sets_strategy(draw, elements=st.integers(), size=5):
    result = set()
    for _ in range(size):
        result.add(draw(elements.filter(lambda x: x not in result)))
    return result

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

Фиксация кейсов

Если мы хотим, чтобы какой-то кейс постоянно проверялся, мы можем добавить декоратор @example.

from hypothesis import given, example
import hypothesis.strategies as st

@given(st.lists(st.integers()))
@example([1,0,0,0])
def test_sort(data):
    sorted_data = sort(data)
    for i in range(len(sorted_data) - 1):
        assert sorted_data[i] <= sorted_data[i+1]

Исключение кейсов

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

Например, возьмем вот такой тест:

from hypothesis import given
import hypothesis.strategies as st

@given(st.floats())
def test_negation_is_self_inverse(x):
    assert x == -(-x)

Hypothesis “верно” определит, что это не будет работать в случае float('nan'):

Falsifying example: test_negation_is_self_inverse(x=float('nan'))
AssertionError

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

from math import isnan

from hypothesis import given, assume
import hypothesis.strategies as st

@given(st.floats())
def test_negation_is_self_inverse_for_non_nan(x):
    assume(not isnan(x))
    assert x == -(-x)

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

Интеграции

На самом деле про интеграции hypothesis можно записывать несколько отдельных занятий, поэтому я перечислю кратко то, что рекомендую использовать:

  • Hypothesis работает из коробки с pytest и unuttest.
  • Hypothesis может использовать схемы Pydantic для генерации примеров через функцию build.
  • Hypothesis может использовать схемы Pandera так как у них есть метод .strategy().
  • hypothesis-csv позволяет генерировать csv файлы.
  • hypothesis-jsonschema даёт возможность использовать JSON schemas для генерации стратегий.

Полезные техники тестирования

Test oracle

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

Roundtrip

Эта техника подходит, если тестируемая функция позволяет проводить обратную операцию. Например, так можно одновременно проверять функции кодирования и декодирования. Так как их последовательное применение, должно вернуть ту же строку.

Strategies as EDA

На самом деле, аналитики данных и так занимаются описанием схем данных, и весьма детальным, когда проводят разведочный анализ. В случае, если они помимо текстового отчета зафиксируют описанные схемы, используя те же dataclass’ы или pydantic/pandera/аналогичное. То тестирование можно начать достаточно рано, так как у вас уже будут готовые схемы для генерации стратегий.

Вывод