Merge branch 'master' into test-389

This commit is contained in:
jsa34 2024-12-01 11:31:30 +00:00 committed by GitHub
commit 07ca9ef0ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 397 additions and 58 deletions

View File

@ -14,6 +14,7 @@ Added
Changed Changed
+++++++ +++++++
* Step arguments ``"datatable"`` and ``"docstring"`` are now reserved, and they can't be used as step argument names.
Deprecated Deprecated
++++++++++ ++++++++++
@ -23,6 +24,8 @@ Removed
Fixed 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 Security
++++++++ ++++++++

View File

@ -513,6 +513,58 @@ Example:
def should_have_left_cucumbers(cucumbers, left): def should_have_left_cucumbers(cucumbers, left):
assert cucumbers["start"] - cucumbers["eat"] == 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: <username>
password: <password>
"""
When the user logs in
Then the response should contain:
| field | value |
| username | <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 Rules
----- -----

View File

@ -3,6 +3,10 @@
from __future__ import annotations from __future__ import annotations
class StepImplementationError(Exception):
"""Step implementation error."""
class ScenarioIsDecoratorOnly(Exception): class ScenarioIsDecoratorOnly(Exception):
"""Scenario can be only used as decorator.""" """Scenario can be only used as decorator."""

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import copy
import os.path import os.path
import re import re
import textwrap import textwrap
@ -20,7 +21,28 @@ from .gherkin_parser import Tag as GherkinTag
from .gherkin_parser import get_gherkin_document from .gherkin_parser import get_gherkin_document
from .types import STEP_TYPE_BY_PARSER_KEYWORD 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]: def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
@ -189,25 +211,25 @@ class ScenarioTemplate:
Returns: Returns:
Scenario: A Scenario object with steps rendered based on the context. Scenario: A Scenario object with steps rendered based on the context.
""" """
base_steps = self.all_background_steps + self._steps
scenario_steps = [ scenario_steps = [
Step( Step(
name=step.render(context), name=render_string(step.name, context),
type=step.type, type=step.type,
indent=step.indent, indent=step.indent,
line_number=step.line_number, line_number=step.line_number,
keyword=step.keyword, keyword=step.keyword,
datatable=step.datatable, datatable=step.render_datatable(step.datatable, context) if step.datatable else None,
docstring=step.docstring, 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( return Scenario(
feature=self.feature, feature=self.feature,
keyword=self.keyword, keyword=self.keyword,
name=self.name, name=render_string(self.name, context),
line_number=self.line_number, line_number=self.line_number,
steps=steps, steps=scenario_steps,
tags=self.tags, tags=self.tags,
description=self.description, description=self.description,
rule=self.rule, rule=self.rule,
@ -299,31 +321,24 @@ class Step:
""" """
return f'{self.type.capitalize()} "{self.name}"' return f'{self.type.capitalize()} "{self.name}"'
@property @staticmethod
def params(self) -> tuple[str, ...]: def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable:
"""Get the parameters in the step name.
Returns:
Tuple[str, ...]: A tuple of parameter names found in the step name.
""" """
return tuple(frozenset(STEP_PARAM_RE.findall(self.name))) Render the datatable with the given context,
but avoid replacing text inside angle brackets if context is missing.
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.
Args: 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: 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.
""" """
rendered_datatable = copy.deepcopy(datatable)
def replacer(m: re.Match) -> str: for row in rendered_datatable.rows:
varname = m.group(1) for cell in row.cells:
# If the context contains the variable, replace it. Otherwise, leave it unchanged. cell.value = render_string(cell.value, context)
return str(context.get(varname, f"<{varname}>")) return rendered_datatable
return STEP_PARAM_RE.sub(replacer, self.name)
@dataclass(eq=False) @dataclass(eq=False)

View File

@ -18,6 +18,7 @@ import logging
import os import os
import re import re
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from inspect import signature
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast
import pytest import pytest
@ -28,7 +29,7 @@ from . import exceptions
from .compat import getfixturedefs, inject_fixture from .compat import getfixturedefs, inject_fixture
from .feature import get_feature, get_features from .feature import get_feature, get_features
from .steps import StepFunctionContext, get_step_fixture_name 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: if TYPE_CHECKING:
from _pytest.mark.structures import ParameterSet from _pytest.mark.structures import ParameterSet
@ -41,10 +42,13 @@ T = TypeVar("T")
logger = logging.getLogger(__name__) 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+_*")
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]]: def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]:
"""Find the fixture defs that can parse a step.""" """Find the fixture defs that can parse a step."""
@ -172,11 +176,35 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
return None 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( def _execute_step_function(
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
) -> None: ) -> None:
"""Execute step function.""" """Execute step function."""
__tracebackhide__ = True __tracebackhide__ = True
func_sig = signature(context.step_func)
kw = { kw = {
"request": request, "request": request,
"feature": scenario.feature, "feature": scenario.feature,
@ -185,38 +213,32 @@ def _execute_step_function(
"step_func": context.step_func, "step_func": context.step_func,
"step_func_args": {}, "step_func_args": {},
} }
request.config.hook.pytest_bdd_before_step(**kw) request.config.hook.pytest_bdd_before_step(**kw)
# Get the step argument values.
converters = context.converters
kwargs = {}
args = get_args(context.step_func)
try: try:
parsed_args = context.parser.parse_arguments(step.name) parsed_args = parse_step_arguments(step=step, context=context)
assert parsed_args is not None, (
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
)
for arg, value in parsed_args.items(): # Filter out the arguments that are not in the function signature
if arg in converters: kwargs = {k: v for k, v in parsed_args.items() if k in func_sig.parameters}
value = converters[arg](value)
kwargs[arg] = value
if step.datatable is not None: if STEP_ARGUMENT_DATATABLE in func_sig.parameters and step.datatable is not None:
kwargs["datatable"] = step.datatable.raw() 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: # Fill the missing arguments requesting the fixture values
kwargs["docstring"] = step.docstring kwargs |= {
arg: request.getfixturevalue(arg) for arg in get_required_args(context.step_func) if arg not in kwargs
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)
# 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) return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs)
except Exception as exception: except Exception as exception:
request.config.hook.pytest_bdd_step_error(exception=exception, **kw) request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
raise raise
@ -269,11 +291,8 @@ def _get_scenario_decorator(
"scenario function can only be used as a decorator. Refer to the documentation." "scenario function can only be used as a decorator. Refer to the documentation."
) )
[fn] = args [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: def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
__tracebackhide__ = True __tracebackhide__ = True
scenario = templated_scenario.render(_pytest_bdd_example) scenario = templated_scenario.render(_pytest_bdd_example)
@ -281,6 +300,11 @@ def _get_scenario_decorator(
fixture_values = [request.getfixturevalue(arg) for arg in func_args] fixture_values = [request.getfixturevalue(arg) for arg in func_args]
return fn(*fixture_values) 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) example_parametrizations = collect_example_parametrizations(templated_scenario)
if example_parametrizations is not None: if example_parametrizations is not None:
# Parametrize the scenario outlines # Parametrize the scenario outlines
@ -295,7 +319,7 @@ def _get_scenario_decorator(
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper) config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
scenario_wrapper.__doc__ = f"{feature_name}: {scenario_name}" 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 cast(Callable[P, T], scenario_wrapper)
return decorator return decorator

View File

@ -20,13 +20,12 @@ T = TypeVar("T")
CONFIG_STACK: list[Config] = [] CONFIG_STACK: list[Config] = []
def get_args(func: Callable[..., Any]) -> list[str]: def get_required_args(func: Callable[..., Any]) -> list[str]:
"""Get a list of argument names for a function. """Get a list of argument that are required for a function.
:param func: The function to inspect. :param func: The function to inspect.
:return: A list of argument names. :return: A list of argument names.
:rtype: list
""" """
params = signature(func).parameters.values() params = signature(func).parameters.values()
return [ return [
@ -83,3 +82,8 @@ def setdefault(obj: object, name: str, default: T) -> T:
except AttributeError: except AttributeError:
setattr(obj, name, default) setattr(obj, name, default)
return default return default
def identity(x: T) -> T:
"""Return the argument."""
return x

View File

@ -210,3 +210,48 @@ def test_steps_with_datatable_missing_argument_in_step(pytester):
) )
result = pytester.runpytest("-s") result = pytester.runpytest("-s")
result.assert_outcomes(passed=1) 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.*"
]
)

View File

@ -199,6 +199,120 @@ def test_angular_brackets_are_not_parsed(pytester):
result.assert_outcomes(passed=2) 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 <background>
And my background has:
"""
Background <background>
"""
Scenario Outline: Outlined scenario
Given I have a templated <foo>
When I have a templated datatable
| <data> |
| example |
And I have a templated docstring
"""
This is a <doc>
"""
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): def test_multilanguage_support(pytester):
"""Test multilanguage support.""" """Test multilanguage support."""
pytester.makefile( pytester.makefile(
@ -279,3 +393,36 @@ def test_multilanguage_support(pytester):
("given", "che uso uno step con ", "esempio 2"), ("given", "che uso uno step con ", "esempio 2"),
("then", "va tutto bene"), ("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"]

View File

@ -193,3 +193,48 @@ def test_docstring_argument_in_step_impl_is_optional(pytester):
) )
result = pytester.runpytest("-s") result = pytester.runpytest("-s")
result.assert_outcomes(passed=1) 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.*"
]
)