Make sure step functions can be reused with converters

This commit is contained in:
Alessio Bogon 2022-07-10 14:20:30 +02:00
parent 55f70cf934
commit 36bb1b9f32
4 changed files with 99 additions and 24 deletions

View File

@ -132,9 +132,9 @@ def _find_step_fixturedef(
if fixturedefs is not None: if fixturedefs is not None:
return fixturedefs return fixturedefs
argumented_step_name = find_argumented_step_fixture_name(name, type_, fixturemanager) step_func_context = find_argumented_step_fixture_name(name, type_, fixturemanager)
if argumented_step_name is not None: if step_func_context is not None:
return fixturemanager.getfixturedefs(argumented_step_name, item.nodeid) return fixturemanager.getfixturedefs(step_func_context.name, item.nodeid)
return None return None

View File

@ -15,6 +15,7 @@ from __future__ import annotations
import collections import collections
import os import os
import re import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, cast from typing import TYPE_CHECKING, Callable, cast
import pytest import pytest
@ -22,6 +23,7 @@ from _pytest.fixtures import FixtureLookupError, FixtureManager, FixtureRequest,
from . import exceptions from . import exceptions
from .feature import get_feature, get_features from .feature import get_feature, get_features
from .parsers import StepParser
from .steps import get_step_fixture_name, inject_fixture from .steps import get_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
@ -36,7 +38,16 @@ PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*") 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.""" """Find argumented step fixture name."""
# 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()): 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 continue
parser_name = get_step_fixture_name(parser.name, type_) 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 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. """Match the step defined by the regular expression pattern.
:param request: PyTest request object. :param request: PyTest request object.
@ -65,15 +81,18 @@ def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario)
:rtype: function :rtype: function
""" """
name = step.name 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 # 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: except FixtureLookupError as e:
try: try:
# Could not find a fixture with the same name, let's see if there is a parser involved # 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) step_func_context = find_argumented_step_fixture_name(name, step.type, request._fixturemanager)
if argumented_name: if step_func_context:
return request.getfixturevalue(argumented_name) request.getfixturevalue(step_func_context.name) # TODO: This shouldn't really be necessary
return step_func_context
raise e raise e
except FixtureLookupError as e2: except FixtureLookupError as e2:
raise exceptions.StepDefinitionNotFoundError( raise exceptions.StepDefinitionNotFoundError(
@ -83,7 +102,7 @@ def _find_step_function(request: FixtureRequest, step: Step, scenario: Scenario)
def _execute_step_function( 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: ) -> None:
"""Execute step function. """Execute step function.
@ -93,30 +112,28 @@ def _execute_step_function(
:param function step_func: Step function. :param function step_func: Step function.
:param example: Example table. :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} kw = {"request": request, "feature": scenario.feature, "scenario": scenario, "step": step, "step_func": step_func}
request.config.hook.pytest_bdd_before_step(**kw) request.config.hook.pytest_bdd_before_step(**kw)
kw["step_func_args"] = {} kw["step_func_args"] = {}
try: try: # TODO: Move this to the places where an exception can actually be raised
# Get the step argument values. # Get the step argument values.
converters = step_func._pytest_bdd_converters converters = step_func_context.converters
kwargs = {} kwargs = {}
for parser in step_func._pytest_bdd_parsers: if step_func_context.parser:
if not parser.is_matching(step.name): for arg, value in step_func_context.parser.parse_arguments(step.name).items():
continue
for arg, value in parser.parse_arguments(step.name).items():
if arg in converters: if arg in converters:
value = converters[arg](value) value = converters[arg](value)
kwargs[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} kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args}
kw["step_func_args"] = kwargs kw["step_func_args"] = kwargs
request.config.hook.pytest_bdd_before_step_call(**kw) 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 # 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) 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 # Execute scenario steps
for step in scenario.steps: for step in scenario.steps:
try: try:
step_func = _find_step_function(request, step, scenario) step_func_context = _find_step_function(request, step, scenario)
except exceptions.StepDefinitionNotFoundError as exception: except exceptions.StepDefinitionNotFoundError as exception:
request.config.hook.pytest_bdd_step_func_lookup_error( request.config.hook.pytest_bdd_step_func_lookup_error(
request=request, feature=feature, scenario=scenario, step=step, exception=exception request=request, feature=feature, scenario=scenario, step=step, exception=exception
) )
raise raise
_execute_step_function(request, scenario, step, step_func) _execute_step_function(request, scenario, step, step_func_context)
finally: finally:
request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario) request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario)

View File

@ -132,9 +132,10 @@ def _step_decorator(
return func return func
lazy_step_func._pytest_bdd_parser = parser_instance lazy_step_func._pytest_bdd_parser = parser_instance
lazy_step_func._pytest_bdd_converters = converters
setdefault(func, "_pytest_bdd_parsers", []).append(parser_instance) setdefault(func, "_pytest_bdd_parsers", []).append(parser_instance)
func._pytest_bdd_converters = converters
func._pytest_bdd_target_fixture = target_fixture func._pytest_bdd_target_fixture = target_fixture
fixture_step_name = get_step_fixture_name(parsed_step_name, step_type) fixture_step_name = get_step_fixture_name(parsed_step_name, step_type)

57
tests/args/test_common.py Normal file
View File

@ -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<value>.*?)$"), converters={"value": int})
@given(parsers.re(r"^I have a foo with str value (?P<value>.*?)$"), converters={"value": str})
@given(parsers.re(r"^I have a foo with float value (?P<value>.*?)$"), 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