Как запустить скрипт как тест pytest

Предположим, у меня есть тест, выраженный в виде простого скрипта с assert-утверждениями (почему см. предысторию), например

import foo
assert foo(3) == 4

Как бы я включил этот скрипт в свой набор тестов pytest - в хорошем смысле?

Я пробовал два рабочих, но менее приятных подхода:

Один из подходов состоит в том, чтобы назвать сценарий как тест, но это приводит к сбою всего обнаружения pytest, когда тест терпит неудачу.

Мой текущий подход состоит в том, чтобы импортировать скрипт из тестовой функции:

def test_notebooks():
    notebook_folder = Path(__file__).parent / 'notebooks'
    for notebook in notebook_folder.glob('*.py'):
        import_module(f'{notebook_folder.name}.{notebook.stem}')

Это работает, но о сценариях не сообщается по отдельности, а сбои тестов имеют длинную и извилистую трассировку стека:

__________________________________________________ test_notebooks ___________________________________________________

    def test_notebooks():
        notebook_folder = Path(__file__).parent / 'notebooks'
        for notebook in notebook_folder.glob('*.py'):
>           import_module(f'{notebook_folder.name}.{notebook.stem}')

test_notebooks.py:7:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
envs\anaconda\lib\importlib\__init__.py:127: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
<frozen importlib._bootstrap>:1006: in _gcd_import
... (9 lines removed)...
<frozen importlib._bootstrap>:219: in _call_with_frames_removed
    ???
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

>   assert False
E   AssertionError

notebooks\notebook_2.py:1: AssertionError

Задний план

Причина, по которой у меня есть тест в файлах сценариев, заключается в том, что они на самом деле являются блокнотами Jupyter, сохраненными в виде .py-файлов с разметкой отличным плагином юпитекст.

Эти блокноты конвертируются в html для документации, могут использоваться в интерактивном режиме для изучения системы и служат дешевыми функциональными тестами.

Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
11
0
6 258
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Выполнение скриптов в тестовой функции

Нет ничего плохого в вызове скриптов из тестовой функции, так что ваш подход вполне подходит. Однако я бы использовал параметризацию вместо запуска сценариев в цикле for; таким образом вы получите тест, выполненный красиво один раз для каждого скрипта. Если вам не нравятся длинные трассировки, вы можете обрезать их в пользовательском хуке pytest_exception_interact. Пример:

# conftest.py

def pytest_exception_interact(node, call, report):
    excinfo = call.excinfo
    if 'script' in node.funcargs:
        excinfo.traceback = excinfo.traceback.cut(path=node.funcargs['script'])
    report.longrepr = node.repr_failure(excinfo)

Параметризованные тесты:

# test_spam.py

import pathlib
import runpy
import pytest

scripts = pathlib.Path(__file__, '..', 'scripts').resolve().glob('*.py')


@pytest.mark.parametrize('script', scripts)
def test_script_execution(script):
    runpy.run_path(script)

Результат выполнения теста (для тестирования я создал простые скрипты с отдельными строками, такими как assert False или 1 / 0:

$ pytest -v
======================================= test session starts ========================================
platform linux -- Python 3.6.8, pytest-4.6.3, py-1.8.0, pluggy-0.12.0 -- /home/hoefling/projects/.venvs/stackoverflow/bin/python3.6
cachedir: .pytest_cache
rootdir: /home/hoefling/projects/private/stackoverflow/so-56807698
plugins: mock-1.10.4, cov-2.7.1, forked-1.0.2, xdist-1.28.0, django-3.4.8
collected 3 items                                                                                  

test_spam.py::test_script_execution[script0] PASSED
test_spam.py::test_script_execution[script1] FAILED
test_spam.py::test_script_execution[script2] FAILED

============================================= FAILURES =============================================
____________________________________ test_script_runpy[script1] ____________________________________

>   assert False
E   AssertionError

scripts/script_3.py:1: AssertionError
____________________________________ test_script_runpy[script2] ____________________________________

>   1 / 0
E   ZeroDivisionError: division by zero

scripts/script_2.py:1: ZeroDivisionError
================================ 2 failed, 1 passed in 0.07 seconds ================================

Пользовательский протокол испытаний

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

# conftest.py

import pathlib
import runpy
import pytest


def pytest_collect_file(parent, path):
    p = pathlib.Path(str(path))
    if p.suffix == '.py' and p.parent.name == 'scripts':
        return Script(path, parent)


class Script(pytest.File):
    def collect(self):
        yield ScriptItem(self.name, self)


class ScriptItem(pytest.Item):
    def runtest(self):
        runpy.run_path(self.fspath)

    def repr_failure(self, excinfo):
        excinfo.traceback = excinfo.traceback.cut(path=self.fspath)
        return super().repr_failure(excinfo)

Это соберет каждый файл .py в каталоге scripts, завернет каждый скрипт в тестовый пример и вызовет runpy при выполнении теста. Журнал выполнения будет выглядеть почти так же, только тесты будут называться по-разному.

Превосходно! Мой план состоял в том, чтобы исследовать в соответствии с протоколом пользовательского тестирования, но комбинация параметризации и runpy над import гораздо менее непрозрачна и именно то, что мне нужно. Отредактировали вопрос, добавив, что мое текущее решение не помечает сценарии по отдельности.

Janus 01.07.2019 08:07

Рад, что смог помочь!

hoefling 01.07.2019 10:53

Для большей удобочитаемости вы можете добавить ids=lambda script: script.stem к вызову pytest.mark.parametrize, чтобы назначить имена файлов скриптов в качестве идентификаторов тестов.

Janus 02.07.2019 10:27

Чтобы эта настройка ids работала, вам нужно сделать примеры списком, а не объектом-генератором. то есть examples = list(pathlib.Path...glob('py'))

kjohnsen 14.10.2021 00:36

Другие вопросы по теме