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

Вводная
У меня был проект, который разрабатывался уже несколько лет. В проекте отсутствовали тесты. А также у него были активные зависимости от других команд, которые также влияли на результат.
Регрессионное тестирование было одним из шагов для более уверенной разработки. Его суть в сравнении вычисленных данных с последним канонизированным результатом работы программы.
Результаты выполнения можно проверять в 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
