From 020c443941e20b39597e9510f47f56e6a179f660 Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 24 Jul 2022 00:02:52 +0200 Subject: [PATCH] Stub fix for finding the right fixture for step definition --- pytest_bdd/scenario.py | 61 ++++++++++++++++++++++++++++++------ pytest_bdd/steps.py | 22 ++++++++++--- tests/library/test_parent.py | 52 +++++++++++++++++++++--------- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 5b6da7d..fee0d25 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -12,16 +12,18 @@ test_publish_article = scenario( """ from __future__ import annotations +import logging import os import re +from pprint import pformat from typing import TYPE_CHECKING, Callable, cast import pytest -from _pytest.fixtures import FixtureManager, FixtureRequest, call_fixture_func +from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func from . import exceptions from .feature import get_feature, get_features -from .steps import StepFunctionContext, inject_fixture +from .steps import StepFunctionContext, get_parsed_step_fixture_name, inject_fixture from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path if TYPE_CHECKING: @@ -31,15 +33,22 @@ if TYPE_CHECKING: from .parser import Feature, Scenario, ScenarioTemplate, Step + +logger = logging.getLogger(__name__) + + PYTHON_REPLACE_REGEX = re.compile(r"\W") ALPHA_REGEX = re.compile(r"^\d+_*") -def find_argumented_step_function(name: str, type_: str, fixturemanager: FixtureManager) -> StepFunctionContext | None: - """Find argumented step fixture name.""" +def iter_argumented_step_function( + name: str, type_: str, fixturemanager: FixtureManager +) -> Iterable[tuple[str, FixtureDef[Any], StepFunctionContext, int]]: + """Iterate over argumented step functions.""" # happens to be that _arg2fixturedefs is changed during the iteration so we use a copy - for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()): - for fixturedef in reversed(fixturedefs): + fixture_def_by_name = list(fixturemanager._arg2fixturedefs.items()) + for i, (fixturename, fixturedefs) in enumerate(reversed(fixture_def_by_name)): + for pos, fixturedef in enumerate(fixturedefs): step_func_context = getattr(fixturedef.func, "_pytest_bdd_step_context", None) if step_func_context is None: continue @@ -51,8 +60,42 @@ def find_argumented_step_function(name: str, type_: str, fixturemanager: Fixture if not match: continue - return step_func_context - return None + yield fixturename, fixturedef, step_func_context, pos + + +def rewrite_argumented_step_functions(name: str, type_, fixturemanager) -> None: + bdd_name = get_parsed_step_fixture_name(name, type_) + + l = list(iter_argumented_step_function(name=name, type_=type_, fixturemanager=fixturemanager)) + l = [f for _, f, _, _ in l] + # TODO: quick n dirty way to give the right priority to the fixtures, but we should be more sophisticated than this. + resorted = sorted(l, key=lambda x: x.baseid) + # l are all the fixture names that parse the current step + added = {} + # TODO: Remove all the injected bdd_name we did here after the execution of the step + # or maybe not, it could be used as a cache? would it poison steps for other scenarios? + for fixturedef in resorted: + existing_defs = fixturemanager._arg2fixturedefs.setdefault(bdd_name, []) + if fixturedef not in existing_defs: + existing_defs.append(fixturedef) + added.setdefault(bdd_name, []).append(fixturedef) + else: + logger.warning("%r already added to bdd name %r, SKIPING", fixturedef, bdd_name) + pass + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Added the following fixtures:\n{pformat(added)}") + + +def find_argumented_step_function( + request, name: str, type_: str, fixturemanager: FixtureManager +) -> StepFunctionContext | None: + """Find argumented step fixture name.""" + rewrite_argumented_step_functions(name=name, type_=type_, fixturemanager=fixturemanager) + bdd_name = get_parsed_step_fixture_name(name, type_) + try: + return request.getfixturevalue(bdd_name) + except pytest.FixtureLookupError: + return None def _execute_step_function( @@ -108,7 +151,7 @@ def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequ request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario) for step in scenario.steps: - context = find_argumented_step_function(step.name, step.type, request._fixturemanager) + context = find_argumented_step_function(request, step.name, step.type, request._fixturemanager) if context is None: exc = exceptions.StepDefinitionNotFoundError( f"Step definition is not found: {step}. " diff --git a/pytest_bdd/steps.py b/pytest_bdd/steps.py index 0d4d01b..9f37192 100644 --- a/pytest_bdd/steps.py +++ b/pytest_bdd/steps.py @@ -71,6 +71,17 @@ def get_step_fixture_name(name: str, type_: str) -> str: return f"pytestbdd_{type_}_{name}" +def get_parsed_step_fixture_name(name: str, type_: str) -> str: + """Get step fixture name. + + :param name: string + :param type: step type + :return: step fixture name + :rtype: string + """ + return f"pytestbdd_parsed_{type_}_{name}" + + def given( name: str | StepParser, converters: dict[str, Callable] | None = None, @@ -142,10 +153,7 @@ def _step_decorator( fixture_step_name = get_step_fixture_name(parsed_step_name, step_type) - def step_function_marker() -> None: - return None - - step_function_marker._pytest_bdd_step_context = StepFunctionContext( + context = StepFunctionContext( name=fixture_step_name, type=step_type, step_func=func, @@ -154,6 +162,12 @@ def _step_decorator( target_fixture=target_fixture, ) + # TODO: Probably we can keep on returning None here instead + def step_function_marker() -> StepFunctionContext: + return context + + step_function_marker._pytest_bdd_step_context = context + caller_locals = get_caller_module_locals() caller_locals[fixture_step_name] = pytest.fixture(name=fixture_step_name)(step_function_marker) return func diff --git a/tests/library/test_parent.py b/tests/library/test_parent.py index 649be6e..cc9e3bb 100644 --- a/tests/library/test_parent.py +++ b/tests/library/test_parent.py @@ -256,10 +256,18 @@ def test_uses_correct_step_in_the_hierarchy(testdir): from pytest_bdd.utils import dump_obj import pytest + @given(parsers.re("(?P.*)")) + def root_conftest_catchall(thing): + dump_obj(thing + " (catchall) root_conftest") + @given(parsers.parse("I have a {thing} thing")) def root_conftest(thing): dump_obj(thing + " root_conftest") + @given("I have a specific thing") + def root_conftest_specific(): + dump_obj("specific" + "(specific) root_conftest") + @then("pass") def _(): pass @@ -281,8 +289,8 @@ def test_uses_correct_step_in_the_hierarchy(testdir): dump_obj(thing + " (catchall) test_a") @given(parsers.parse("I have a specific thing")) - def in_root_test_a_specific(thing): - dump_obj(thing + " (specific) test_a") + def in_root_test_a_specific(): + dump_obj("specific" + " (specific) test_a") @given(parsers.parse("I have a {thing} thing")) def in_root_test_a(thing): @@ -299,8 +307,8 @@ def test_uses_correct_step_in_the_hierarchy(testdir): dump_obj(thing + " (catchall) test_c") @given(parsers.parse("I have a specific thing")) - def in_root_test_c_specific(thing): - dump_obj(thing + " (specific) test_c") + def in_root_test_c_specific(): + dump_obj("specific" + " (specific) test_c") @given(parsers.parse("I have a {thing} thing")) def in_root_test_c(thing): @@ -322,8 +330,8 @@ def test_uses_correct_step_in_the_hierarchy(testdir): dump_obj(thing + " (catchall) test_b_test_a") @given(parsers.parse("I have a specific thing")) - def in_test_b_test_a_specific(thing): - dump_obj(thing + " (specific) test_b_test_a") + def in_test_b_test_a_specific(): + dump_obj("specific" + " (specific) test_b_test_a") @given(parsers.parse("I have a {thing} thing")) def in_test_b_test_a(thing): @@ -343,8 +351,8 @@ def test_uses_correct_step_in_the_hierarchy(testdir): dump_obj(thing + " (catchall) test_b_test_c") @given(parsers.parse("I have a specific thing")) - def in_test_b_test_c_specific(thing): - dump_obj(thing + " (specific) test_a_test_c") + def in_test_b_test_c_specific(): + dump_obj("specific" + " (specific) test_a_test_c") @given(parsers.parse("I have a {thing} thing")) def in_test_b_test_c(thing): @@ -365,18 +373,34 @@ def test_uses_correct_step_in_the_hierarchy(testdir): scenarios("../specific.feature") - # Important here to have the parse argument different from the others, - # otherwise test would succeed even if the wrong step was used. + @given(parsers.parse("I have a {thing} thing")) + def in_test_b_test_b(thing): + dump_obj(f"{thing} test_b_test_b") + """ + ) + ) + + test_b_folder.join("test_b_alternative.py").write( + textwrap.dedent( + """\ + from pytest_bdd import scenarios, given, parsers + from pytest_bdd.utils import dump_obj + + + scenarios("../specific.feature") + + + # Here we try to use an argument different from the others, + # to make sure it doesn't matter if a new step parser string is encountered. @given(parsers.parse("I have a {t} thing")) def in_test_b_test_b(t): dump_obj(f"{t} test_b_test_b") - """ ) ) result = testdir.runpytest("-s") - result.assert_outcomes(passed=1) + result.assert_outcomes(passed=2) - [thing] = collect_dumped_objects(result) - assert thing == "specific test_b_test_b" + [thing1, thing2] = collect_dumped_objects(result) + assert thing1 == thing2 == "specific test_b_test_b"