From 36bb1b9f324ea9bb15fa951720646fd5b9f48daf Mon Sep 17 00:00:00 2001 From: Alessio Bogon <778703+youtux@users.noreply.github.com> Date: Sun, 10 Jul 2022 14:20:30 +0200 Subject: [PATCH] Make sure step functions can be reused with converters --- pytest_bdd/generation.py | 6 ++--- pytest_bdd/scenario.py | 57 +++++++++++++++++++++++++-------------- pytest_bdd/steps.py | 3 ++- tests/args/test_common.py | 57 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 24 deletions(-) create mode 100644 tests/args/test_common.py diff --git a/pytest_bdd/generation.py b/pytest_bdd/generation.py index 24145eb..691afa6 100644 --- a/pytest_bdd/generation.py +++ b/pytest_bdd/generation.py @@ -132,9 +132,9 @@ def _find_step_fixturedef( if fixturedefs is not None: return fixturedefs - argumented_step_name = find_argumented_step_fixture_name(name, type_, fixturemanager) - if argumented_step_name is not None: - return fixturemanager.getfixturedefs(argumented_step_name, item.nodeid) + step_func_context = find_argumented_step_fixture_name(name, type_, fixturemanager) + if step_func_context is not None: + return fixturemanager.getfixturedefs(step_func_context.name, item.nodeid) return None diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index ebdeee9..dd8d86f 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -15,6 +15,7 @@ from __future__ import annotations import collections import os import re +from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, cast import pytest @@ -22,6 +23,7 @@ from _pytest.fixtures import FixtureLookupError, FixtureManager, FixtureRequest, from . import exceptions from .feature import get_feature, get_features +from .parsers import StepParser from .steps import get_step_fixture_name, inject_fixture from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path @@ -36,7 +38,16 @@ PYTHON_REPLACE_REGEX = re.compile(r"\W") ALPHA_REGEX = re.compile(r"^\d+_*") -def find_argumented_step_fixture_name(name: str, type_: str, fixturemanager: FixtureManager) -> str | None: +@dataclass +class StepFunctionContext: + name: str + parser: StepParser | None = None + converters: dict[str, Callable[..., Any]] | None = None + + +def find_argumented_step_fixture_name( + name: str, type_: str, fixturemanager: FixtureManager +) -> StepFunctionContext | None: """Find argumented step fixture name.""" # happens to be that _arg2fixturedefs is changed during the iteration so we use a copy for fixturename, fixturedefs in list(fixturemanager._arg2fixturedefs.items()): @@ -50,11 +61,16 @@ def find_argumented_step_fixture_name(name: str, type_: str, fixturemanager: Fix continue parser_name = get_step_fixture_name(parser.name, type_) - return parser_name + + return StepFunctionContext( + name=parser_name, + parser=parser, + converters=fixturedef.func._pytest_bdd_converters, + ) return None -def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario) -> Callable[..., Any]: +def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario) -> StepFunctionContext: """Match the step defined by the regular expression pattern. :param request: PyTest request object. @@ -65,15 +81,18 @@ def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario) :rtype: function """ name = step.name - try: + try: # TODO: wrap this try only to around the part that can raise # Simple case where no parser is used for the step - return request.getfixturevalue(get_step_fixture_name(name, step.type)) + candidate_name = get_step_fixture_name(name, step.type) + request.getfixturevalue(candidate_name) + return StepFunctionContext(name=candidate_name) except FixtureLookupError as e: try: # Could not find a fixture with the same name, let's see if there is a parser involved - argumented_name = find_argumented_step_fixture_name(name, step.type, request._fixturemanager) - if argumented_name: - return request.getfixturevalue(argumented_name) + step_func_context = find_argumented_step_fixture_name(name, step.type, request._fixturemanager) + if step_func_context: + request.getfixturevalue(step_func_context.name) # TODO: This shouldn't really be necessary + return step_func_context raise e except FixtureLookupError as e2: raise exceptions.StepDefinitionNotFoundError( @@ -83,7 +102,7 @@ def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario) def _execute_step_function( - request: FixtureRequest, scenario: Scenario, step: Step, step_func: Callable[..., Any] + request: FixtureRequest, scenario: Scenario, step: Step, step_func_context: StepFunctionContext ) -> None: """Execute step function. @@ -93,30 +112,28 @@ def _execute_step_function( :param function step_func: Step function. :param example: Example table. """ + step_func = request.getfixturevalue(step_func_context.name) kw = {"request": request, "feature": scenario.feature, "scenario": scenario, "step": step, "step_func": step_func} request.config.hook.pytest_bdd_before_step(**kw) kw["step_func_args"] = {} - try: + try: # TODO: Move this to the places where an exception can actually be raised # Get the step argument values. - converters = step_func._pytest_bdd_converters + converters = step_func_context.converters kwargs = {} - for parser in step_func._pytest_bdd_parsers: - if not parser.is_matching(step.name): - continue - for arg, value in parser.parse_arguments(step.name).items(): + if step_func_context.parser: + for arg, value in step_func_context.parser.parse_arguments(step.name).items(): if arg in converters: value = converters[arg](value) kwargs[arg] = value - break - args = get_args(step_func) + args = get_args(step_func) # TODO: Add args to step_function_context? kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args} kw["step_func_args"] = kwargs request.config.hook.pytest_bdd_before_step_call(**kw) - target_fixture = step_func._pytest_bdd_target_fixture + target_fixture = step_func._pytest_bdd_target_fixture # TODO: Add target fixture to the step function context # Execute the step as if it was a pytest fixture, so that we can allow "yield" statements in it return_value = call_fixture_func(fixturefunc=step_func, request=request, kwargs=kwargs) @@ -143,13 +160,13 @@ def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequ # Execute scenario steps for step in scenario.steps: try: - step_func = _find_step_function(request, step, scenario) + step_func_context = _find_step_function(request, step, scenario) except exceptions.StepDefinitionNotFoundError as exception: request.config.hook.pytest_bdd_step_func_lookup_error( request=request, feature=feature, scenario=scenario, step=step, exception=exception ) raise - _execute_step_function(request, scenario, step, step_func) + _execute_step_function(request, scenario, step, step_func_context) finally: request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario) diff --git a/pytest_bdd/steps.py b/pytest_bdd/steps.py index d3b52f5..eee15a6 100644 --- a/pytest_bdd/steps.py +++ b/pytest_bdd/steps.py @@ -132,9 +132,10 @@ def _step_decorator( return func lazy_step_func._pytest_bdd_parser = parser_instance + lazy_step_func._pytest_bdd_converters = converters setdefault(func, "_pytest_bdd_parsers", []).append(parser_instance) - func._pytest_bdd_converters = converters + func._pytest_bdd_target_fixture = target_fixture fixture_step_name = get_step_fixture_name(parsed_step_name, step_type) diff --git a/tests/args/test_common.py b/tests/args/test_common.py new file mode 100644 index 0000000..b7357b1 --- /dev/null +++ b/tests/args/test_common.py @@ -0,0 +1,57 @@ +import textwrap + +from pytest_bdd.utils import collect_dumped_objects + + +def test_reuse_same_step_different_converters(testdir): + testdir.makefile( + ".feature", + arguments=textwrap.dedent( + """\ + Feature: Reuse same step with different converters + Scenario: Step function should be able to be decorated multiple times with different converters + Given I have a foo with int value 42 + And I have a foo with str value 42 + And I have a foo with float value 42 + When pass + Then pass + """ + ), + ) + + testdir.makepyfile( + textwrap.dedent( + r""" + import pytest + from pytest_bdd import parsers, given, when, then, scenarios + from pytest_bdd.utils import dump_obj + + scenarios("arguments.feature") + + @given(parsers.re(r"^I have a foo with int value (?P.*?)$"), converters={"value": int}) + @given(parsers.re(r"^I have a foo with str value (?P.*?)$"), converters={"value": str}) + @given(parsers.re(r"^I have a foo with float value (?P.*?)$"), converters={"value": float}) + def _(value): + dump_obj(value) + return value + + + @then("pass") + @when("pass") + def _(): + pass + """ + ) + ) + result = testdir.runpytest("-s") + result.assert_outcomes(passed=1) + + [int_value, str_value, float_value] = collect_dumped_objects(result) + assert type(int_value) is int + assert int_value == 42 + + assert type(str_value) is str + assert str_value == "42" + + assert type(float_value) is float + assert float_value == 42.0