Используйте Hypothesis для автоматизации создания тестового примера

Модульное тестирование является отраслевым стандартом де-факто, используемым во всех профессиональных проектах по программному обеспечению, как и должно быть. Мы обсуждали больше в разных постах (введение модульного тестирования для Python и мокирование) и создали аналогичные статьи для C++ (Часть 1 / Часть 2).

Однако это не означает, что модульное тестирование идеально. Одним из недостатков является присущая ему природа, основанная на примерах: разработчики пишут модульные тесты в соответствии со своим пониманием своего кода, следуя своим представлениям о правильном/неправильном, что создает ограничения для рассматриваемой функции. Когда модульный тест терпит неудачу, мы, безусловно, находим ошибку в коде (при условии, что тест был правильным), но когда он проходит, нам остается только задаться вопросом: охватывает ли этот тест все соответствующие свойства рассматриваемой функции? Охватывает ли он все угловые случаи?

Следуя вышеизложенным, кажется естественным ввести тесты на основе свойств. Они происходят из мира функционального программирования и, как следует из названия, определяют желаемые свойства функции. Рассмотрим, например, функцию кодировщика/декодера. Кажется естественным ожидать dec(enc(x)) = x. Наряду с этим мы внедряем автоматическую генерацию тестовых случаев. Только при сочетании этого и свойств тестирования тестовых случаев мы получаем большую мощность, чем в «обычных» модульных тестах (аналогично, тестирование на основе свойств позволило автоматически генерировать тестовые примеры, так как теперь нам не нужно знать точный ответ для каждого прецедент).

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

Обратите внимание, что тестирование на основе свойств не должно заменять тестирование на основе примеров, но может быть полезным дополнением. Мы обсудим полезные свойства позже, но мы не можем протестировать все с помощью этого метода. Мы ограничены тем, что можем выразить как «свойство». Кроме того, мы должны учитывать дополнительное время, необходимое для запуска тестов, поскольку Hypothesis создает N случайных тестовых случаев и выполняет их все.

Вводный пример

Прежде чем углубляться, давайте начнем с простого вводного примера. Для этого рассмотрим вышеупомянутый кодировщик/пример. Мы реализуем простой шифр, напоминающий знаменитый шифр Цезаря: каждая буква открытого текста заменяется другой, которая следует за c символами в алфавите позже исходной буквы — enc(“hello”, 1) дает ifmmp. Кроме того, мы добавили простой параметризованный модульный тест в тот же файл (только для демонстрационных целей). Вот как это выглядит:

import pytest


def encode(plaintext: str, key: int) -> str:
    return "".join(chr(ord(c) + key) for c in plaintext)


def decode(ciphertext: str, key: int) -> str:
    return "".join(chr(ord(c) - key) for c in ciphertext)


@pytest.mark.parametrize(
    "plaintext, key, expected_ciphertext",
    [("test", 0, "test"), ("hello", 1, "ifmmp")],
)
def test_encode(plaintext: str, key: int, expected_ciphertext: str) -> None:
    assert encode(plaintext, key) == expected_ciphertext

Мы можем запустить этот тестовый файл через python -m pytest main.py.

Теперь давайте добавим тест с Hypothesis:

import pytest
from hypothesis import given
from hypothesis import strategies as st


def encode(plaintext: str, key: int) -> str:
    return "".join(chr(ord(c) + key) for c in plaintext)


def decode(ciphertext: str, key: int) -> str:
    return "".join(chr(ord(c) - key) for c in ciphertext)


@pytest.mark.parametrize(
    "plaintext, key, expected_ciphertext",
    [("test", 0, "test"), ("hello", 1, "ifmmp")],
)
def test_encode(plaintext: str, key: int, expected_ciphertext: str) -> None:
    assert encode(plaintext, key) == expected_ciphertext


@given(s=st.text(), k=st.integers(0, 26))
def test_decode_inverts_encode(s: str, k: int) -> None:
    assert decode(encode(s, k), k) == s

Как мы видим, мы говорим Hypothesis генерировать случайные открытые тексты s, а также ключи k и проверять свойство обратимости. Мы разрешаем s быть любым текстом, но ограничиваем k диапазоном [0, 26], т. е. некоторым символоподобным смещением, чтобы избежать переполнения/неполного заполнения для функции ord (на что Hypothesis правильно указывает иначе!)

Особенности гипотезы

При запуске теста Hypothesis по умолчанию создаст 100 случайных тестовых случаев и выполнит их (обратите внимание на вводный комментарий о времени выполнения. Если это критично для вас, будьте осторожны). Интересно взглянуть на внутренности Hypothesis, что вы можете легко сделать, добавив тестовые входные данные в глобальный список, а затем распечатав его.

Сокращение тестовых случаев

Еще одной интересной особенностью Hypothesis является сокращение тестовых случаев, которое выполняется за счет сжатия. Это означает, что Hypothesis всегда будет выводить «самый простой» фальсифицирующий пример. Рассмотрим следующий искусственный тестовый пример, так как он выдает ошибку, если переданное целое больше 10:

@given(i=st.integers())
def test_ints(i: int) -> None:
    assert i < 10

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

Сохранение фальсифицирующих примеров

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

Эта база данных находится в папке .hypothesis в каталоге, из которого мы запускаем тест. Удаление очищает базу данных.

Использование гипотезы

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

Стратегии

Гипотеза определяет широкий спектр «стратегий», которые мы можем использовать для создания тестовых случаев. Мы использовали некоторые из них раньше (например, st.integers()).

Самые основные из них: booleans, integers, floats, text. Давайте напишем фиктивный тест, проверяющий, действительно ли сгенерированные входные данные соответствуют нашим ожиданиям:

@given(i=st.integers(), b=st.booleans(), t=st.text(), f=st.floats(-10, 10))
def test_strategies(i: int, b: bool, t: str, f: float) -> None:
    assert type(i) == int
    assert type(b) == bool
    assert type(t) == str
    assert type(f) == float
    assert f >= -10
    assert f <= 10

Как мы видим и видели выше, некоторые стратегии принимают дополнительные аргументы, такие как минимальные/максимальные значения для чисел.

Мы можем дополнительно комбинировать такие примитивные типы со сложными, например, lists. В следующем примере мы проверяем встроенную в Python функцию sort(). В частности, мы проверяем, что количество элементов в массиве не меняется после сортировки, что отсортированный массив содержит одни и те же элементы и что они действительно отсортированы. Причины такого использования будут обсуждаться в следующем разделе. Вот код:

@given(arr=st.lists(st.integers()))
def test_sort(arr: List[int]) -> None:
    copied_arr = [x for x in arr]
    arr.sort()
    assert len(copied_arr) == len(arr)
    assert set(arr) == set(copied_arr)
    for i in range(len(arr) - 1):
        assert arr[i] <= arr[i+1]

Карта/Фильтр

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

@given(i=st.integers().filter(lambda x: x % 2 == 0))
def test_even_filter(i: int) -> None:
    assert i % 2 == 0

@given(i=st.integers().map(lambda x: x * 2))
def test_even_filter(i: int) -> None:
    assert i % 2 == 0

Пример

Иногда вы уже знаете конкретные сбои, которые хотите протестировать. В этом случае может помочь декоратор @example. Вот один из способов его использования:

from hypothesis import example

@given(i=st.integers(), j=st.integers())
@example(i=5, j=0)
def test_divide(i: int, j: int) -> None:
    i / j

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

Полезные свойства

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

Поездка туда и обратно

Это свойство предполагает существование обратной функции, которую мы можем использовать для проверки того, что inv(f(x)) = x. Мы видели это в нашем вводном примере, используя функции кодирования и декодирования как инверсные.

Эквивалентная функция

Иногда будет эквивалентная функция, которую мы можем использовать для проверки результата проверки нашей функции. Некоторые примеры включают тестирование недавно рефакторинговой функции со старой, надежной функцией, сравнение однопоточного и многопоточного выполнения или сравнение функций Python с реализациями Numpy/Pytorch.

Чтобы продемонстрировать это, давайте реализуем алгоритм пузырьковой сортировки:

def bubble_sort(arr: List[int]) -> List[int]:
    for i in range(len(arr)):
        for j in range(0, len(arr) - i - 1):
            if arr[j] > arr[j + 1]:
                temp = arr[j]
                arr[j] = arr[j + 1]
                arr[j + 1] = temp

Затем давайте проверим это с помощью функции Python sort():

@given(arr=st.lists(st.integers()))
def test_bubble_sort(arr: List[int]) -> None:
    copied_arr = [x for x in arr]
    bubble_sort(arr)
    copied_arr.sort()
    assert arr == copied_arr

Другие примеры

Однако почти всегда будут «очевидные» свойства функции, которую вы хотите/должны протестировать. Давайте вернемся к нашему предыдущему примерному тесту sort(). В этом примере мы проверили, что массив по-прежнему содержит одни и те же элементы после сортировки и когда они находятся в отсортированном порядке. Эти два свойства полностью определяют сортировку.

На этом мы подошли к концу этого введения в тестирование на основе свойств в Python. Я надеюсь, что это пригодится вам однажды!