Support for pytest 6 "--import-mode=importlib" (#384)

* Fix compatibility with pytest 6 "--import-mode=importlib"

* Rewrite `scenario` and `scenarios` tests so that we can easily parametrize with the different pytest --import-mode.

* Update changelog
This commit is contained in:
Alessio Bogon 2020-09-07 09:43:29 +02:00 committed by GitHub
parent 882291524b
commit 853c615748
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 133 additions and 73 deletions

View File

@ -1,14 +1,16 @@
Changelog Changelog
========= =========
This relase introduces breaking changes, please refer to the :ref:`Migration from 3.x.x`. This release introduces breaking changes, please refer to the :ref:`Migration from 3.x.x`.
- Strict Gherkin option is removed. (olegpidsadnyi) - Strict Gherkin option is removed (``@scenario()`` does not accept the ``strict_gherkin`` parameter). (olegpidsadnyi)
- ``@scenario()`` does not accept the undocumented parameter ``caller_module`` anymore. (youtux)
- Given step is no longer a fixture. The scope parameter is also removed. (olegpidsadnyi) - Given step is no longer a fixture. The scope parameter is also removed. (olegpidsadnyi)
- Fixture parameter is removed from the given step declaration. (olegpidsadnyi) - Fixture parameter is removed from the given step declaration. (olegpidsadnyi)
- ``pytest_bdd_step_validation_error`` hook is removed. (olegpidsadnyi) - ``pytest_bdd_step_validation_error`` hook is removed. (olegpidsadnyi)
- Fix an error with pytest-pylint plugin #374. (toracle) - Fix an error with pytest-pylint plugin #374. (toracle)
- Fix pytest-xdist 2.0 compatibility #369. (olegpidsadnyi) - Fix pytest-xdist 2.0 compatibility #369. (olegpidsadnyi)
- Fix compatibility with pytest 6 ``--import-mode=importlib`` option. (youtux)
3.4.0 3.4.0

View File

@ -14,6 +14,7 @@ import collections
import inspect import inspect
import os import os
import re import re
import sys
import pytest import pytest
@ -24,9 +25,8 @@ except ImportError:
from . import exceptions from . import exceptions
from .feature import Feature, force_unicode, get_features from .feature import Feature, force_unicode, get_features
from .steps import get_caller_module, get_step_fixture_name, inject_fixture from .steps import get_step_fixture_name, inject_fixture
from .utils import CONFIG_STACK, get_args from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
PYTHON_REPLACE_REGEX = re.compile(r"\W") PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*") ALPHA_REGEX = re.compile(r"^\d+_*")
@ -197,14 +197,7 @@ def _get_scenario_decorator(feature, feature_name, scenario, scenario_name, enco
return decorator return decorator
def scenario( def scenario(feature_name, scenario_name, encoding="utf-8", example_converters=None, features_base_dir=None):
feature_name,
scenario_name,
encoding="utf-8",
example_converters=None,
caller_module=None,
features_base_dir=None,
):
"""Scenario decorator. """Scenario decorator.
:param str feature_name: Feature file name. Absolute or relative to the configured feature base path. :param str feature_name: Feature file name. Absolute or relative to the configured feature base path.
@ -215,11 +208,11 @@ def scenario(
""" """
scenario_name = force_unicode(scenario_name, encoding) scenario_name = force_unicode(scenario_name, encoding)
caller_module = caller_module or get_caller_module() caller_module_path = get_caller_module_path()
# Get the feature # Get the feature
if features_base_dir is None: if features_base_dir is None:
features_base_dir = get_features_base_dir(caller_module) features_base_dir = get_features_base_dir(caller_module_path)
feature = Feature.get_feature(features_base_dir, feature_name, encoding=encoding) feature = Feature.get_feature(features_base_dir, feature_name, encoding=encoding)
# Get the scenario # Get the scenario
@ -242,8 +235,8 @@ def scenario(
) )
def get_features_base_dir(caller_module): def get_features_base_dir(caller_module_path):
default_base_dir = os.path.dirname(caller_module.__file__) default_base_dir = os.path.dirname(caller_module_path)
return get_from_ini("bdd_features_base_dir", default_base_dir) return get_from_ini("bdd_features_base_dir", default_base_dir)
@ -293,12 +286,12 @@ def scenarios(*feature_paths, **kwargs):
:param *feature_paths: feature file paths to use for scenarios :param *feature_paths: feature file paths to use for scenarios
""" """
frame = inspect.stack()[1] caller_locals = get_caller_module_locals()
module = inspect.getmodule(frame[0]) caller_path = get_caller_module_path()
features_base_dir = kwargs.get("features_base_dir") features_base_dir = kwargs.get("features_base_dir")
if features_base_dir is None: if features_base_dir is None:
features_base_dir = get_features_base_dir(module) features_base_dir = get_features_base_dir(caller_path)
abs_feature_paths = [] abs_feature_paths = []
for path in feature_paths: for path in feature_paths:
@ -309,7 +302,7 @@ def scenarios(*feature_paths, **kwargs):
module_scenarios = frozenset( module_scenarios = frozenset(
(attr.__scenario__.feature.filename, attr.__scenario__.name) (attr.__scenario__.feature.filename, attr.__scenario__.name)
for name, attr in module.__dict__.items() for name, attr in caller_locals.items()
if hasattr(attr, "__scenario__") if hasattr(attr, "__scenario__")
) )
@ -323,9 +316,9 @@ def scenarios(*feature_paths, **kwargs):
pass # pragma: no cover pass # pragma: no cover
for test_name in get_python_name_generator(scenario_name): for test_name in get_python_name_generator(scenario_name):
if test_name not in module.__dict__: if test_name not in caller_locals:
# found an unique test name # found an unique test name
module.__dict__[test_name] = _scenario caller_locals[test_name] = _scenario
break break
found = True found = True
if not found: if not found:

View File

@ -37,7 +37,6 @@ def given_beautiful_article(article):
from __future__ import absolute_import from __future__ import absolute_import
import inspect import inspect
import sys
import pytest import pytest
@ -49,7 +48,7 @@ except ImportError:
from .feature import force_encode from .feature import force_encode
from .types import GIVEN, WHEN, THEN from .types import GIVEN, WHEN, THEN
from .parsers import get_parser from .parsers import get_parser
from .utils import get_args from .utils import get_args, get_caller_module_locals
def get_step_fixture_name(name, type_, encoding=None): def get_step_fixture_name(name, type_, encoding=None):
@ -137,21 +136,15 @@ def _step_decorator(step_type, step_name, converters=None, target_fixture=None):
step_func.target_fixture = lazy_step_func.target_fixture = target_fixture step_func.target_fixture = lazy_step_func.target_fixture = target_fixture
lazy_step_func = pytest.fixture()(lazy_step_func) lazy_step_func = pytest.fixture()(lazy_step_func)
setattr(get_caller_module(), get_step_fixture_name(parsed_step_name, step_type), lazy_step_func) fixture_step_name = get_step_fixture_name(parsed_step_name, step_type)
caller_locals = get_caller_module_locals()
caller_locals[fixture_step_name] = lazy_step_func
return func return func
return decorator return decorator
def get_caller_module(depth=2):
"""Return the module of the caller."""
frame = sys._getframe(depth)
module = inspect.getmodule(frame)
if module is None:
return get_caller_module(depth=depth)
return module
def inject_fixture(request, arg, value): def inject_fixture(request, arg, value):
"""Inject fixture into pytest fixture request. """Inject fixture into pytest fixture request.

View File

@ -2,6 +2,8 @@
import inspect import inspect
import six
CONFIG_STACK = [] CONFIG_STACK = []
@ -17,12 +19,12 @@ def get_args(func):
:return: A list of argument names. :return: A list of argument names.
:rtype: list :rtype: list
""" """
if hasattr(inspect, "signature"): if six.PY2:
params = inspect.signature(func).parameters.values()
return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD]
else:
return inspect.getargspec(func).args return inspect.getargspec(func).args
params = inspect.signature(func).parameters.values()
return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD]
def get_parametrize_markers_args(node): def get_parametrize_markers_args(node):
"""In pytest 3.6 new API to access markers has been introduced and it deprecated """In pytest 3.6 new API to access markers has been introduced and it deprecated
@ -31,3 +33,14 @@ def get_parametrize_markers_args(node):
This function uses that API if it is available otherwise it uses MarkInfo objects. This function uses that API if it is available otherwise it uses MarkInfo objects.
""" """
return tuple(arg for mark in node.iter_markers("parametrize") for arg in mark.args) return tuple(arg for mark in node.iter_markers("parametrize") for arg in mark.args)
def get_caller_module_locals(depth=2):
frame_info = inspect.stack()[depth]
frame = frame_info[0] # frame_info.frame
return frame.f_locals
def get_caller_module_path(depth=2):
frame_info = inspect.stack()[depth]
return frame_info[1] # frame_info.filename

View File

@ -1 +1,23 @@
from __future__ import absolute_import, unicode_literals
import pytest
from tests.utils import PYTEST_6
pytest_plugins = "pytester" pytest_plugins = "pytester"
def pytest_generate_tests(metafunc):
if "pytest_params" in metafunc.fixturenames:
if PYTEST_6:
parametrizations = [
pytest.param([], id="no-import-mode"),
pytest.param(["--import-mode=prepend"], id="--import-mode=prepend"),
pytest.param(["--import-mode=append"], id="--import-mode=append"),
pytest.param(["--import-mode=importlib"], id="--import-mode=importlib"),
]
else:
parametrizations = [[]]
metafunc.parametrize(
"pytest_params",
parametrizations,
)

View File

@ -5,7 +5,7 @@ import textwrap
from tests.utils import assert_outcomes from tests.utils import assert_outcomes
def test_scenario_not_found(testdir): def test_scenario_not_found(testdir, pytest_params):
"""Test the situation when scenario is not found.""" """Test the situation when scenario is not found."""
testdir.makefile( testdir.makefile(
".feature", ".feature",
@ -30,7 +30,7 @@ def test_scenario_not_found(testdir):
""" """
) )
) )
result = testdir.runpytest() result = testdir.runpytest_subprocess(*pytest_params)
assert_outcomes(result, errors=1) assert_outcomes(result, errors=1)
result.stdout.fnmatch_lines('*Scenario "NOT FOUND" in feature "Scenario is not found" in*') result.stdout.fnmatch_lines('*Scenario "NOT FOUND" in feature "Scenario is not found" in*')
@ -90,8 +90,12 @@ def test_scenario_comments(testdir):
) )
) )
result = testdir.runpytest()
def test_scenario_not_decorator(testdir): result.assert_outcomes(passed=2)
def test_scenario_not_decorator(testdir, pytest_params):
"""Test scenario function is used not as decorator.""" """Test scenario function is used not as decorator."""
testdir.makefile( testdir.makefile(
".feature", ".feature",
@ -109,7 +113,38 @@ def test_scenario_not_decorator(testdir):
""" """
) )
result = testdir.runpytest() result = testdir.runpytest_subprocess(*pytest_params)
result.assert_outcomes(failed=1) result.assert_outcomes(failed=1)
result.stdout.fnmatch_lines("*ScenarioIsDecoratorOnly: scenario function can only be used as a decorator*") result.stdout.fnmatch_lines("*ScenarioIsDecoratorOnly: scenario function can only be used as a decorator*")
def test_simple(testdir, pytest_params):
"""Test scenario decorator with a standard usage."""
testdir.makefile(
".feature",
simple="""
Feature: Simple feature
Scenario: Simple scenario
Given I have a bar
""",
)
testdir.makepyfile(
"""
from pytest_bdd import scenario, given, then
@scenario("simple.feature", "Simple scenario")
def test_simple():
pass
@given("I have a bar")
def bar():
return "bar"
@then("pass")
def bar():
pass
"""
)
result = testdir.runpytest_subprocess(*pytest_params)
result.assert_outcomes(passed=1)

View File

@ -1,9 +1,11 @@
"""Test scenarios shortcut.""" """Test scenarios shortcut."""
import textwrap import textwrap
from tests.utils import assert_outcomes
def test_scenarios(testdir):
"""Test scenarios shortcut.""" def test_scenarios(testdir, pytest_params):
"""Test scenarios shortcut (used together with @scenario for individual test override)."""
testdir.makeini( testdir.makeini(
""" """
[pytest] [pytest]
@ -63,7 +65,8 @@ def test_scenarios(testdir):
scenarios('features') scenarios('features')
""" """
) )
result = testdir.runpytest("-v", "-s") result = testdir.runpytest_subprocess("-v", "-s", *pytest_params)
assert_outcomes(result, passed=4, failed=1)
result.stdout.fnmatch_lines(["*collected 5 items"]) result.stdout.fnmatch_lines(["*collected 5 items"])
result.stdout.fnmatch_lines(["*test_test_subfolder_scenario *bar!", "PASSED"]) result.stdout.fnmatch_lines(["*test_test_subfolder_scenario *bar!", "PASSED"])
result.stdout.fnmatch_lines(["*test_test_scenario *bar!", "PASSED"]) result.stdout.fnmatch_lines(["*test_test_scenario *bar!", "PASSED"])
@ -72,7 +75,7 @@ def test_scenarios(testdir):
result.stdout.fnmatch_lines(["*test_test_scenario_1 *bar!", "PASSED"]) result.stdout.fnmatch_lines(["*test_test_scenario_1 *bar!", "PASSED"])
def test_scenarios_none_found(testdir): def test_scenarios_none_found(testdir, pytest_params):
"""Test scenarios shortcut when no scenarios found.""" """Test scenarios shortcut when no scenarios found."""
testpath = testdir.makepyfile( testpath = testdir.makepyfile(
""" """
@ -82,6 +85,6 @@ def test_scenarios_none_found(testdir):
scenarios('.') scenarios('.')
""" """
) )
reprec = testdir.inline_run(testpath) result = testdir.runpytest_subprocess(testpath, *pytest_params)
reprec.assertoutcome(failed=1) assert_outcomes(result, errors=1)
assert "NoScenariosFound" in str(reprec.getreports()[1].longrepr) result.stdout.fnmatch_lines(["*NoScenariosFound*"])

View File

@ -53,12 +53,11 @@ def test_preserve_decorator(testdir, step, keyword):
from pytest_bdd import {step} from pytest_bdd import {step}
from pytest_bdd.steps import get_step_fixture_name from pytest_bdd.steps import get_step_fixture_name
@{step}("{keyword}")
def func():
"""Doc string."""
def test_decorator(): def test_decorator():
@{step}("{keyword}")
def func():
"""Doc string."""
assert globals()[get_step_fixture_name("{keyword}", {step}.__name__)].__doc__ == "Doc string." assert globals()[get_step_fixture_name("{keyword}", {step}.__name__)].__doc__ == "Doc string."

View File

@ -4,10 +4,27 @@ import pytest
from packaging.utils import Version from packaging.utils import Version
PYTEST_VERSION = Version(pytest.__version__) PYTEST_VERSION = Version(pytest.__version__)
PYTEST_6 = PYTEST_VERSION >= Version("6")
_errors_key = "error" if PYTEST_VERSION < Version("6") else "errors"
if PYTEST_VERSION < Version("6"): if PYTEST_6:
def assert_outcomes(
result,
passed=0,
skipped=0,
failed=0,
errors=0,
xpassed=0,
xfailed=0,
):
"""Compatibility function for result.assert_outcomes"""
return result.assert_outcomes(
errors=errors, passed=passed, skipped=skipped, failed=failed, xpassed=xpassed, xfailed=xfailed
)
else:
def assert_outcomes( def assert_outcomes(
result, result,
@ -27,20 +44,3 @@ if PYTEST_VERSION < Version("6"):
xpassed=xpassed, xpassed=xpassed,
xfailed=xfailed, xfailed=xfailed,
) )
else:
def assert_outcomes(
result,
passed=0,
skipped=0,
failed=0,
errors=0,
xpassed=0,
xfailed=0,
):
"""Compatibility function for result.assert_outcomes"""
return result.assert_outcomes(
errors=errors, passed=passed, skipped=skipped, failed=failed, xpassed=xpassed, xfailed=xfailed
)