И вынести тестируемые результаты вне кода. Это статья об автоматизации и увеличения удобства тестирования на Python.


image


Вводная


У меня был проект, который разрабатывался уже несколько лет. В проекте отсутствовали тесты. А также у него были активные зависимости от других команд, которые также влияли на результат.


Регрессионное тестирование было одним из шагов для более уверенной разработки. Его суть в сравнении вычисленных данных с последним канонизированным результатом работы программы. 


Результаты выполнения можно проверять в python коде тестов. Это близко к контексту выполнения и зачастую удобно.


Но это также может быть неудобно когда:


  • Переводим из одного представления в другое. Например, из Python объектов в xml, из json в объекты или из объектов в текстовые файлы. Большой форматированный строковый текст будет смотреться в тестах неряшливо, даже если к нему применили textwrap.dedent.
  • Нет инструментов обновления данных при корректных изменениях. Копировать и менять под тесты выхлоп сгенеренных значений это скучная рутинная задача.
  • Это фаза спасения проекта. Тестов нет или мало, а изменения вносить нужно. Понимать что сломал уже на продакшене хочется меньше. Такое тестирование станет первым дешёвым в исполнении шагом.

Подход к решению


Я пытался подобрать решение к этим задачам, а получилась своя небольшая библиотека. Вот как эти задачи решаются с помощью библиотеки testoot для Питона 3.4+.


Подход используется в модульных и юнит тестах. Файлы результатов складываются в отдельной директории репозитория.


Интересующая нас функция foo генерирует результат вычисления. Добавляем возвращаемое значение в тест:


def foo():
    return {'a': 1}

def test_simple(testoot: Testoot):
    testoot.test(foo())

Запустим тесты с pytest:


pytest -s tests

В первый запуск автоматически создастся файл результата и сохранится в директории тестов. В последующие запуски вычисленное будет сверяться с записанным значением. Что такое фикстура testoot типа Testoot опишу ниже.


Если после первого запуска изменим возвращаемое значение на другое:


def foo():
    return {'a': 2}

def test_simple(testoot: Testoot):
    testoot.test(foo())

То получим AssertionError при запуске тестов:


...?def test_simple(testoot: Testoot):
>       testoot.test(foo()))

cls = <class 'testoot.ext.pytest.PytestComparator'>, test_obj = {'a': 2}, canon_obj = {'a': 1}

    @classmethod
    def compare(cls, test_obj: any, canon_obj: any):
        """Compares objects"""
>       assert test_obj == canon_obj
E       AssertionError

Сохранение новых данных


Посмотрим как можно сохранить новое значение в тестах. По-умолчанию тесты запускаются в автоматическом режиме и не спрашивают пользователя об изменении сохранённых данных.


Перезапустим тесты с флагом --canonize.


pytest -s tests --canonize

Теперь покажется сравнение в том же виде, в котором даёт pytest (флаги --verbose также работают):


tests/test_console/test_console.py [tests/test_console/test_console.py::test_simple]
{'a': 2} == {'a': 1}
~Differing items:
~{'a': 2} != {'a': 1}
~Use -v to get the full diff
Canonize [yn]? y
.

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


Результаты хранятся в файлах репозитория рядом с кодом. Текущие форматы сериалиации: 


  • бинарный pickle. Сериализует почти всё из Python. Но формат бинарный и разрешать конфликты в любимой VCS без помощи программ читающих pickle может быть проблематично
  • бинарный bytes. Записывает любые данные, но также сложно разрешать конфликты.
  • текстовый str. Только utf-8 строки, но легко читается и разрешается с конфликтами.
  • json формат. Только json, зато отформатированный с отступами, поэтому смотреть диффы удобно.

Идеального формата для задач, где и поддерживаются все типы и в VCS легко повторно проконтролировать изменения пока нет.


Ещё из возможностей. Генерируем файл и отправляем содержимое под наблюдение.


def test_filename(testoot: Testoot):
    d = Path(testoot.storage.root_dir / 'hello.json')
    d.write_text('{}')

    testoot.test_filename(str(d))

Как конфигурировать


Выше использовали фикстуры типа Testoot. Рассмотрим ближе что это такое:


import pytest

from testoot.ext.pytest import PytestContext
from testoot.ext.simple import DefaultBaseTestoot
from testoot.pub import AskCanonizePolicy, PickleSerializer,     LocalDirectoryStorage, ConsoleUserInteraction, Testoot

@pytest.fixture(scope='module')
def base_testoot():
    regress = DefaultBaseTestoot(
        storage=LocalDirectoryStorage('.testoot'),
    )
    regress.storage.ensure_exists()
    yield regress

@pytest.fixture(scope='function')
def testoot(base_testoot, request):
    fixture = Testoot(base_testoot, PytestContext(request))
    yield fixture

DefaultBaseTestoot это базовый объект с логикой тестирования. Для создания Testoot к нему добавляется контекст теста (scope='function'). Из pytest фикстуры request узнаём название теста, с которым сохраняем получившийся результат.


Сам DefaultBaseTestoot это BaseTestoot с заданными настройками по-умолчанию: сохраняем данные в директорию .testoot с pickle-сериализатором, включаем канонизацию при флаге --canonize.


Если хотим изменить сериализатор по-умолчанию:


regress = DefaultBaseTestoot(
    serializer=JsonSerializer(),
)

Точно также можно менять хранилище и компаратор для объектов. Компаратор для pytest выглядит стандартно и переопределяется для нужных тестов:


class PytestComparator(Comparator):
    @classmethod
    def compare(cls, test_obj: any, canon_obj: any):
        """Compares objects"""
        assert test_obj == canon_obj

Сериализатор и компаратор переопределяются на уровне контекста PytestContext(request, serializer=BinarySerializer(), comparator=PytestComparator()). Или уже на уровне самого теста:


def test_str(testoot: Testoot):
    result = 'abc'
    regress.test(result, serializer=StringSerializer(), comparator=PytestComparator())

Переопределения в тесте имеют приоритет над контекстом, которые сами приоритетнее значений в BaseTestoot.


Важно учесть


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


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


Заключение


Это был обзор тестирования и возможностей библиотеки для Python. Буду рад комментариям и предложениям!


Установка: pip3 install testoot
Документация: https://testoot.readthedocs.io
Исходный код: https://github.com/aptakhin/testoot