И вынести тестируемые результаты вне кода. Это статья об автоматизации и увеличения удобства тестирования на 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
zloddey
Спасибо за статью (и библиотеку)!
Данный подход ещё известен под названием Approval Testing. Пара аналогичных библиотек для тестирования под Python уже существуют, но они далеки от идеала. Так что альтернативный движок будет не лишним.
Parander Автор
Круто! Спасибо за термин.