Как не править Python тесты

от автора

И вынести тестируемые результаты вне кода. Это статья об автоматизации и увеличения удобства тестирования на 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

ссылка на оригинал статьи https://habr.com/ru/post/502278/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *