diff --git a/CHANGES.rst b/CHANGES.rst index 1e8ae705..c441165b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,7 @@ Added Changed +++++++ +* Step arguments ``"datatable"`` and ``"docstring"`` are now reserved, and they can't be used as step argument names. Deprecated ++++++++++ @@ -23,6 +24,8 @@ Removed Fixed +++++ +* Fixed an issue with the upcoming pytest release related to the use of ``@pytest.mark.usefixtures`` with an empty list. +* Render template variables in docstrings and datatable cells with example table entries, as we already do for steps definitions. Security ++++++++ diff --git a/README.rst b/README.rst index 7af83821..837a6d60 100644 --- a/README.rst +++ b/README.rst @@ -513,6 +513,58 @@ Example: def should_have_left_cucumbers(cucumbers, left): assert cucumbers["start"] - cucumbers["eat"] == left + +Example parameters from example tables can not only be used in steps, but also embedded directly within docstrings and datatables, allowing for dynamic substitution. +This provides added flexibility for scenarios that require complex setups or validations. + +Example: + +.. code-block:: gherkin + + # content of docstring_and_datatable_with_params.feature + + Feature: Docstring and Datatable with example parameters + Scenario Outline: Using parameters in docstrings and datatables + Given the following configuration: + """ + username: + password: + """ + When the user logs in + Then the response should contain: + | field | value | + | username | | + | logged_in | true | + + Examples: + | username | password | + | user1 | pass123 | + | user2 | 123secure | + +.. code-block:: python + + from pytest_bdd import scenarios, given, when, then + import json + + # Load scenarios from the feature file + scenarios("docstring_and_datatable_with_params.feature") + + + @given("the following configuration:") + def given_user_config(docstring): + print(docstring) + + + @when("the user logs in") + def user_logs_in(logged_in): + logged_in = True + + + @then("the response should contain:") + def response_should_contain(datatable): + assert datatable[1][1] in ["user1", "user2"] + + Rules ----- diff --git a/src/pytest_bdd/exceptions.py b/src/pytest_bdd/exceptions.py index 1baf617e..b46a8221 100644 --- a/src/pytest_bdd/exceptions.py +++ b/src/pytest_bdd/exceptions.py @@ -3,6 +3,10 @@ from __future__ import annotations +class StepImplementationError(Exception): + """Step implementation error.""" + + class ScenarioIsDecoratorOnly(Exception): """Scenario can be only used as decorator.""" diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index ff4a0619..2ede93ca 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import os.path import re import textwrap @@ -20,7 +21,28 @@ from .gherkin_parser import get_gherkin_document from .types import STEP_TYPE_BY_PARSER_KEYWORD -STEP_PARAM_RE = re.compile(r"<(.+?)>") +PARAM_RE = re.compile(r"<(.+?)>") + + +def render_string(input_string: str, render_context: Mapping[str, object]) -> str: + """ + Render the string with the given context, + but avoid replacing text inside angle brackets if context is missing. + + Args: + input_string (str): The string for which to render/replace params. + render_context (Mapping[str, object]): The context for rendering the string. + + Returns: + str: The rendered string with parameters replaced only if they exist in the context. + """ + + def replacer(m: re.Match) -> str: + varname = m.group(1) + # If the context contains the variable, replace it. Otherwise, leave it unchanged. + return str(render_context.get(varname, f"<{varname}>")) + + return PARAM_RE.sub(replacer, input_string) def get_tag_names(tag_data: list[GherkinTag]) -> set[str]: @@ -189,25 +211,25 @@ def render(self, context: Mapping[str, Any]) -> Scenario: Returns: Scenario: A Scenario object with steps rendered based on the context. """ + base_steps = self.all_background_steps + self._steps scenario_steps = [ Step( - name=step.render(context), + name=render_string(step.name, context), type=step.type, indent=step.indent, line_number=step.line_number, keyword=step.keyword, - datatable=step.datatable, - docstring=step.docstring, + datatable=step.render_datatable(step.datatable, context) if step.datatable else None, + docstring=render_string(step.docstring, context) if step.docstring else None, ) - for step in self._steps + for step in base_steps ] - steps = self.all_background_steps + scenario_steps return Scenario( feature=self.feature, keyword=self.keyword, - name=self.name, + name=render_string(self.name, context), line_number=self.line_number, - steps=steps, + steps=scenario_steps, tags=self.tags, description=self.description, rule=self.rule, @@ -299,31 +321,24 @@ def __str__(self) -> str: """ return f'{self.type.capitalize()} "{self.name}"' - @property - def params(self) -> tuple[str, ...]: - """Get the parameters in the step name. - - Returns: - Tuple[str, ...]: A tuple of parameter names found in the step name. + @staticmethod + def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable: """ - return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) - - def render(self, context: Mapping[str, Any]) -> str: - """Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing. + Render the datatable with the given context, + but avoid replacing text inside angle brackets if context is missing. Args: - context (Mapping[str, Any]): The context for rendering the step name. + datatable (DataTable): The datatable to render. + context (Mapping[str, Any]): The context for rendering the datatable. Returns: - str: The rendered step name with parameters replaced only if they exist in the context. + datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context. """ - - def replacer(m: re.Match) -> str: - varname = m.group(1) - # If the context contains the variable, replace it. Otherwise, leave it unchanged. - return str(context.get(varname, f"<{varname}>")) - - return STEP_PARAM_RE.sub(replacer, self.name) + rendered_datatable = copy.deepcopy(datatable) + for row in rendered_datatable.rows: + for cell in row.cells: + cell.value = render_string(cell.value, context) + return rendered_datatable @dataclass(eq=False) diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index ac8844c5..cb2a126f 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -18,6 +18,7 @@ import os import re from collections.abc import Iterable, Iterator +from inspect import signature from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast import pytest @@ -28,7 +29,7 @@ from .compat import getfixturedefs, inject_fixture from .feature import get_feature, get_features from .steps import StepFunctionContext, get_step_fixture_name -from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path +from .utils import CONFIG_STACK, get_caller_module_locals, get_caller_module_path, get_required_args, identity if TYPE_CHECKING: from _pytest.mark.structures import ParameterSet @@ -41,10 +42,13 @@ logger = logging.getLogger(__name__) - PYTHON_REPLACE_REGEX = re.compile(r"\W") ALPHA_REGEX = re.compile(r"^\d+_*") +STEP_ARGUMENT_DATATABLE = "datatable" +STEP_ARGUMENT_DOCSTRING = "docstring" +STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING} + def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]: """Find the fixture defs that can parse a step.""" @@ -172,11 +176,35 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex return None +def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object]: + """Parse step arguments.""" + parsed_args = context.parser.parse_arguments(step.name) + + assert parsed_args is not None, ( + f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}" + ) + + reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES + if reserved_args: + reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args) + raise exceptions.StepImplementationError( + f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. " + "Please use different names." + ) + + converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()} + + return converted_args + + def _execute_step_function( request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext ) -> None: """Execute step function.""" __tracebackhide__ = True + + func_sig = signature(context.step_func) + kw = { "request": request, "feature": scenario.feature, @@ -185,38 +213,32 @@ def _execute_step_function( "step_func": context.step_func, "step_func_args": {}, } - request.config.hook.pytest_bdd_before_step(**kw) - # Get the step argument values. - converters = context.converters - kwargs = {} - args = get_args(context.step_func) - try: - parsed_args = context.parser.parse_arguments(step.name) - assert parsed_args is not None, ( - f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}" - ) + parsed_args = parse_step_arguments(step=step, context=context) - for arg, value in parsed_args.items(): - if arg in converters: - value = converters[arg](value) - kwargs[arg] = value + # Filter out the arguments that are not in the function signature + kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters} - if step.datatable is not None: - kwargs["datatable"] = step.datatable.raw() + if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None: + kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw() + if STEP_ARGUMENT_DOCSTRING in func_sig.parameters and step.docstring is not None: + kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring - if step.docstring is not None: - kwargs["docstring"] = step.docstring - - kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args} + # Fill the missing arguments requesting the fixture values + kwargs |= { + arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in kwargs + } kw["step_func_args"] = kwargs request.config.hook.pytest_bdd_before_step_call(**kw) - # 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 using `call_fixture_func`, + # so that we can allow "yield" statements in it return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs) + except Exception as exception: request.config.hook.pytest_bdd_step_error(exception=exception, **kw) raise @@ -269,11 +291,8 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]: "scenario function can only be used as a decorator. Refer to the documentation." ) [fn] = args - func_args = get_args(fn) + func_args = get_required_args(fn) - # We need to tell pytest that the original function requires its fixtures, - # otherwise indirect fixtures would not work. - @pytest.mark.usefixtures(*func_args) def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any: __tracebackhide__ = True scenario = templated_scenario.render(_pytest_bdd_example) @@ -281,6 +300,11 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str fixture_values = [request.getfixturevalue(arg) for arg in func_args] return fn(*fixture_values) + if func_args: + # We need to tell pytest that the original function requires its fixtures, + # otherwise indirect fixtures would not work. + scenario_wrapper = pytest.mark.usefixtures(*func_args)(scenario_wrapper) + example_parametrizations = collect_example_parametrizations(templated_scenario) if example_parametrizations is not None: # Parametrize the scenario outlines @@ -295,7 +319,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" - scenario_wrapper.__scenario__ = templated_scenario + scenario_wrapper.__scenario__ = templated_scenario # type: ignore[attr-defined] return cast(Callable[P, T], scenario_wrapper) return decorator diff --git a/src/pytest_bdd/utils.py b/src/pytest_bdd/utils.py index 1e9946c4..a72c86df 100644 --- a/src/pytest_bdd/utils.py +++ b/src/pytest_bdd/utils.py @@ -20,13 +20,12 @@ CONFIG_STACK: list[Config] = [] -def get_args(func: Callable[..., Any]) -> list[str]: - """Get a list of argument names for a function. +def get_required_args(func: Callable[..., Any]) -> list[str]: + """Get a list of argument that are required for a function. :param func: The function to inspect. :return: A list of argument names. - :rtype: list """ params = signature(func).parameters.values() return [ @@ -83,3 +82,8 @@ def setdefault(obj: object, name: str, default: T) -> T: except AttributeError: setattr(obj, name, default) return default + + +def identity(x: T) -> T: + """Return the argument.""" + return x diff --git a/tests/datatable/test_datatable.py b/tests/datatable/test_datatable.py index ddb4ee94..47a04d11 100644 --- a/tests/datatable/test_datatable.py +++ b/tests/datatable/test_datatable.py @@ -210,3 +210,48 @@ def test_datatable(): ) result = pytester.runpytest("-s") result.assert_outcomes(passed=1) + + +def test_datatable_step_argument_is_reserved_and_cannot_be_used(pytester): + pytester.makefile( + ".feature", + reserved_datatable_arg=textwrap.dedent( + """\ + Feature: Reserved datatable argument + + Scenario: Reserved datatable argument + Given this step has a {datatable} argument + Then the test fails + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario, given, then, parsers + + @scenario("reserved_datatable_arg.feature", "Reserved datatable argument") + def test_datatable(): + pass + + + @given(parsers.parse("this step has a {datatable} argument")) + def _(datatable): + pass + + + @then("the test fails") + def _(): + pass + """ + ) + ) + + result = pytester.runpytest() + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*Step 'this step has a {datatable} argument' defines argument names that are reserved: 'datatable'. Please use different names.*" + ] + ) diff --git a/tests/feature/test_scenario.py b/tests/feature/test_scenario.py index b8664e21..19c5ccf6 100644 --- a/tests/feature/test_scenario.py +++ b/tests/feature/test_scenario.py @@ -199,6 +199,120 @@ def _(): result.assert_outcomes(passed=2) +def test_example_params(pytester): + """Test example params are rendered where necessary: + * Step names + * Docstring + * Datatables + """ + pytester.makefile( + ".feature", + example_params=''' + Feature: Example params + Background: + Given I have a background + And my background has: + """ + Background + """ + + Scenario Outline: Outlined scenario + Given I have a templated + When I have a templated datatable + | | + | example | + And I have a templated docstring + """ + This is a + """ + Then pass + + Examples: + | background | foo | data | doc | + | parameter | bar | table | string | + ''', + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, when, then, parsers + from pytest_bdd.utils import dump_obj + + scenarios("example_params.feature") + + + @given(parsers.parse("I have a background {background}")) + def _(background): + return dump_obj(("background", background)) + + + @given(parsers.parse("I have a templated {foo}")) + def _(foo): + return "foo" + + + @given("my background has:") + def _(docstring): + return dump_obj(("background_docstring", docstring)) + + + @given("I have a rule table:") + def _(datatable): + return dump_obj(("rule", datatable)) + + + @when("I have a templated datatable") + def _(datatable): + return dump_obj(("datatable", datatable)) + + + @when("I have a templated docstring") + def _(docstring): + return dump_obj(("docstring", docstring)) + + + @then("pass") + def _(): + pass + """ + ) + result = pytester.runpytest("-s") + result.assert_outcomes(passed=1) + + assert collect_dumped_objects(result) == [ + ("background", "parameter"), + ("background_docstring", "Background parameter"), + ("datatable", [["table"], ["example"]]), + ("docstring", "This is a string"), + ] + + +def test_step_parser_argument_not_in_function_signature_does_not_fail(pytester): + """Test that if the step parser defines an argument, but step function does not accept it, + then it does not fail and the params is just not filled.""" + + pytester.makefile( + ".feature", + simple=""" + Feature: Simple feature + Scenario: Step with missing argument + Given a user with username "user1" + """, + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, parsers + + scenarios("simple.feature") + + @given(parsers.parse('a user with username "{username}"')) + def create_user(): + pass + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + def test_multilanguage_support(pytester): """Test multilanguage support.""" pytester.makefile( @@ -279,3 +393,36 @@ def _(): ("given", "che uso uno step con ", "esempio 2"), ("then", "va tutto bene"), ] + + +def test_default_value_is_used_as_fallback(pytester): + """Test that the default value for a step implementation is only used as a fallback.""" + pytester.makefile( + ".feature", + simple=""" + Feature: Simple feature + Scenario: Step using default arg + Given a user with default username + + Scenario: Step using explicit value + Given a user with username "user1" + """, + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios, given, then, parsers + from pytest_bdd.utils import dump_obj + + scenarios("simple.feature") + + @given('a user with default username', target_fixture="user") + @given(parsers.parse('a user with username "{username}"'), target_fixture="user") + def create_user(username="defaultuser"): + dump_obj(username) + + """ + ) + result = pytester.runpytest("-s") + result.assert_outcomes(passed=2) + + assert collect_dumped_objects(result) == ["defaultuser", "user1"] diff --git a/tests/steps/test_docstring.py b/tests/steps/test_docstring.py index bfa5e524..81d63c2b 100644 --- a/tests/steps/test_docstring.py +++ b/tests/steps/test_docstring.py @@ -193,3 +193,48 @@ def _(): ) result = pytester.runpytest("-s") result.assert_outcomes(passed=1) + + +def test_docstring_step_argument_is_reserved_and_cannot_be_used(pytester): + pytester.makefile( + ".feature", + reserved_docstring_arg=textwrap.dedent( + """\ + Feature: Reserved docstring argument + + Scenario: Reserved docstring argument + Given this step has a {docstring} argument + Then the test fails + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario, given, then, parsers + + @scenario("reserved_docstring_arg.feature", "Reserved docstring argument") + def test_docstring(): + pass + + + @given(parsers.parse("this step has a {docstring} argument")) + def _(docstring): + pass + + + @then("the test fails") + def _(): + pass + """ + ) + ) + + result = pytester.runpytest() + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*Step 'this step has a {docstring} argument' defines argument names that are reserved: 'docstring'. Please use different names.*" + ] + )