Как не править Python тесты
- среда, 20 мая 2020 г. в 00:32:37
И вынести тестируемые результаты вне кода. Это статья об автоматизации и увеличения удобства тестирования на Python.
У меня был проект, который разрабатывался уже несколько лет. В проекте отсутствовали тесты. А также у него были активные зависимости от других команд, которые также влияли на результат.
Регрессионное тестирование было одним из шагов для более уверенной разработки. Его суть в сравнении вычисленных данных с последним канонизированным результатом работы программы.
Результаты выполнения можно проверять в python коде тестов. Это близко к контексту выполнения и зачастую удобно.
Но это также может быть неудобно когда:
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
.
На вопрос канонизировать ли тест можем ответить утвердительно и тогда будет сохранены новые данные. В противном случае выводится ошибка как и без флага запуска.
Результаты хранятся в файлах репозитория рядом с кодом. Текущие форматы сериалиации:
Идеального формата для задач, где и поддерживаются все типы и в 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