Stub fix for finding the right fixture for step definition

This commit is contained in:
Alessio Bogon 2022-07-24 00:02:52 +02:00
parent 2c8d1552e0
commit 020c443941
3 changed files with 108 additions and 27 deletions

View File

@ -12,16 +12,18 @@ test_publish_article = scenario(
""" """
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import re import re
from pprint import pformat
from typing import TYPE_CHECKING, Callable, cast from typing import TYPE_CHECKING, Callable, cast
import pytest 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 . import exceptions
from .feature import get_feature, get_features 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 from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
if TYPE_CHECKING: if TYPE_CHECKING:
@ -31,15 +33,22 @@ if TYPE_CHECKING:
from .parser import Feature, Scenario, ScenarioTemplate, Step from .parser import Feature, Scenario, ScenarioTemplate, Step
logger = logging.getLogger(__name__)
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+_*")
def find_argumented_step_function(name: str, type_: str, fixturemanager: FixtureManager) -> StepFunctionContext | None: def iter_argumented_step_function(
"""Find argumented step fixture name.""" 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 # happens to be that _arg2fixturedefs is changed during the iteration so we use a copy
for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()): fixture_def_by_name = list(fixturemanager._arg2fixturedefs.items())
for fixturedef in reversed(fixturedefs): 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) step_func_context = getattr(fixturedef.func, "_pytest_bdd_step_context", None)
if step_func_context is None: if step_func_context is None:
continue continue
@ -51,8 +60,42 @@ def find_argumented_step_function(name: str, type_: str, fixturemanager: Fixture
if not match: if not match:
continue continue
return step_func_context yield fixturename, fixturedef, step_func_context, pos
return None
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( 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) request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario)
for step in scenario.steps: 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: if context is None:
exc = exceptions.StepDefinitionNotFoundError( exc = exceptions.StepDefinitionNotFoundError(
f"Step definition is not found: {step}. " f"Step definition is not found: {step}. "

View File

@ -71,6 +71,17 @@ def get_step_fixture_name(name: str, type_: str) -> str:
return f"pytestbdd_{type_}_{name}" 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( def given(
name: str | StepParser, name: str | StepParser,
converters: dict[str, Callable] | None = None, 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) fixture_step_name = get_step_fixture_name(parsed_step_name, step_type)
def step_function_marker() -> None: context = StepFunctionContext(
return None
step_function_marker._pytest_bdd_step_context = StepFunctionContext(
name=fixture_step_name, name=fixture_step_name,
type=step_type, type=step_type,
step_func=func, step_func=func,
@ -154,6 +162,12 @@ def _step_decorator(
target_fixture=target_fixture, 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 = get_caller_module_locals()
caller_locals[fixture_step_name] = pytest.fixture(name=fixture_step_name)(step_function_marker) caller_locals[fixture_step_name] = pytest.fixture(name=fixture_step_name)(step_function_marker)
return func return func

View File

@ -256,10 +256,18 @@ def test_uses_correct_step_in_the_hierarchy(testdir):
from pytest_bdd.utils import dump_obj from pytest_bdd.utils import dump_obj
import pytest import pytest
@given(parsers.re("(?P<thing>.*)"))
def root_conftest_catchall(thing):
dump_obj(thing + " (catchall) root_conftest")
@given(parsers.parse("I have a {thing} thing")) @given(parsers.parse("I have a {thing} thing"))
def root_conftest(thing): def root_conftest(thing):
dump_obj(thing + " root_conftest") dump_obj(thing + " root_conftest")
@given("I have a specific thing")
def root_conftest_specific():
dump_obj("specific" + "(specific) root_conftest")
@then("pass") @then("pass")
def _(): def _():
pass pass
@ -281,8 +289,8 @@ def test_uses_correct_step_in_the_hierarchy(testdir):
dump_obj(thing + " (catchall) test_a") dump_obj(thing + " (catchall) test_a")
@given(parsers.parse("I have a specific thing")) @given(parsers.parse("I have a specific thing"))
def in_root_test_a_specific(thing): def in_root_test_a_specific():
dump_obj(thing + " (specific) test_a") dump_obj("specific" + " (specific) test_a")
@given(parsers.parse("I have a {thing} thing")) @given(parsers.parse("I have a {thing} thing"))
def in_root_test_a(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") dump_obj(thing + " (catchall) test_c")
@given(parsers.parse("I have a specific thing")) @given(parsers.parse("I have a specific thing"))
def in_root_test_c_specific(thing): def in_root_test_c_specific():
dump_obj(thing + " (specific) test_c") dump_obj("specific" + " (specific) test_c")
@given(parsers.parse("I have a {thing} thing")) @given(parsers.parse("I have a {thing} thing"))
def in_root_test_c(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") dump_obj(thing + " (catchall) test_b_test_a")
@given(parsers.parse("I have a specific thing")) @given(parsers.parse("I have a specific thing"))
def in_test_b_test_a_specific(thing): def in_test_b_test_a_specific():
dump_obj(thing + " (specific) test_b_test_a") dump_obj("specific" + " (specific) test_b_test_a")
@given(parsers.parse("I have a {thing} thing")) @given(parsers.parse("I have a {thing} thing"))
def in_test_b_test_a(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") dump_obj(thing + " (catchall) test_b_test_c")
@given(parsers.parse("I have a specific thing")) @given(parsers.parse("I have a specific thing"))
def in_test_b_test_c_specific(thing): def in_test_b_test_c_specific():
dump_obj(thing + " (specific) test_a_test_c") dump_obj("specific" + " (specific) test_a_test_c")
@given(parsers.parse("I have a {thing} thing")) @given(parsers.parse("I have a {thing} thing"))
def in_test_b_test_c(thing): def in_test_b_test_c(thing):
@ -365,18 +373,34 @@ def test_uses_correct_step_in_the_hierarchy(testdir):
scenarios("../specific.feature") scenarios("../specific.feature")
# Important here to have the parse argument different from the others, @given(parsers.parse("I have a {thing} thing"))
# otherwise test would succeed even if the wrong step was used. 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")) @given(parsers.parse("I have a {t} thing"))
def in_test_b_test_b(t): def in_test_b_test_b(t):
dump_obj(f"{t} test_b_test_b") dump_obj(f"{t} test_b_test_b")
""" """
) )
) )
result = testdir.runpytest("-s") result = testdir.runpytest("-s")
result.assert_outcomes(passed=1) result.assert_outcomes(passed=2)
[thing] = collect_dumped_objects(result) [thing1, thing2] = collect_dumped_objects(result)
assert thing == "specific test_b_test_b" assert thing1 == thing2 == "specific test_b_test_b"