Skip to content

Commit 4eb54fd

Browse files
committed
Raise an error if a step defines reserved argument names
This can cause headaches in the future, when users can't figure out why their step argument 'datatable' or 'docstring' does not get the value they expect
1 parent c70f526 commit 4eb54fd

File tree

5 files changed

+128
-18
lines changed

5 files changed

+128
-18
lines changed

src/pytest_bdd/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
from __future__ import annotations
44

55

6+
class StepImplementationError(Exception):
7+
"""Step implementation error."""
8+
9+
610
class ScenarioIsDecoratorOnly(Exception):
711
"""Scenario can be only used as decorator."""
812

src/pytest_bdd/scenario.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .compat import getfixturedefs, inject_fixture
2929
from .feature import get_feature, get_features
3030
from .steps import StepFunctionContext, get_step_fixture_name
31-
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path
31+
from .utils import CONFIG_STACK, get_args, get_caller_module_locals, get_caller_module_path, identity
3232

3333
if TYPE_CHECKING:
3434
from _pytest.mark.structures import ParameterSet
@@ -41,10 +41,13 @@
4141

4242
logger = logging.getLogger(__name__)
4343

44-
4544
PYTHON_REPLACE_REGEX = re.compile(r"\W")
4645
ALPHA_REGEX = re.compile(r"^\d+_*")
4746

47+
STEP_ARGUMENT_DATATABLE = "datatable"
48+
STEP_ARGUMENT_DOCSTRING = "docstring"
49+
STEP_ARGUMENTS_RESERVED_NAMES = {STEP_ARGUMENT_DATATABLE, STEP_ARGUMENT_DOCSTRING}
50+
4851

4952
def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, node: Node) -> Iterable[FixtureDef[Any]]:
5053
"""Find the fixture defs that can parse a step."""
@@ -172,6 +175,27 @@ def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContex
172175
return None
173176

174177

178+
def parse_step_arguments(step: Step, context: StepFunctionContext) -> dict[str, object] | None:
179+
"""Parse step arguments."""
180+
parsed_args = context.parser.parse_arguments(step.name)
181+
182+
assert parsed_args is not None, (
183+
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
184+
)
185+
186+
reserved_args = set(parsed_args.keys()) & STEP_ARGUMENTS_RESERVED_NAMES
187+
if reserved_args:
188+
reserved_arguments_str = ", ".join(repr(arg) for arg in reserved_args)
189+
raise exceptions.StepImplementationError(
190+
f"Step {step.name!r} defines argument names that are reserved: {reserved_arguments_str}. "
191+
"Please use different names."
192+
)
193+
194+
converted_args = {key: (context.converters.get(key, identity)(value)) for key, value in parsed_args.items()}
195+
196+
return converted_args
197+
198+
175199
def _execute_step_function(
176200
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
177201
) -> None:
@@ -185,30 +209,17 @@ def _execute_step_function(
185209
"step_func": context.step_func,
186210
"step_func_args": {},
187211
}
188-
189212
request.config.hook.pytest_bdd_before_step(**kw)
190-
191-
# Get the step argument values.
192-
converters = context.converters
193-
kwargs = {}
194213
args = get_args(context.step_func)
195214

196215
try:
197-
parsed_args = context.parser.parse_arguments(step.name)
198-
assert parsed_args is not None, (
199-
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
200-
)
201-
202-
for arg, value in parsed_args.items():
203-
if arg in converters:
204-
value = converters[arg](value)
205-
kwargs[arg] = value
216+
kwargs = parse_step_arguments(step=step, context=context)
206217

207218
if step.datatable is not None:
208-
kwargs["datatable"] = step.datatable.raw()
219+
kwargs[STEP_ARGUMENT_DATATABLE] = step.datatable.raw()
209220

210221
if step.docstring is not None:
211-
kwargs["docstring"] = step.docstring
222+
kwargs[STEP_ARGUMENT_DOCSTRING] = step.docstring
212223

213224
kwargs = {arg: kwargs[arg] if arg in kwargs else request.getfixturevalue(arg) for arg in args}
214225

src/pytest_bdd/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ def setdefault(obj: object, name: str, default: T) -> T:
8383
except AttributeError:
8484
setattr(obj, name, default)
8585
return default
86+
87+
88+
def identity(x: T) -> T:
89+
"""Return the argument."""
90+
return x

tests/datatable/test_datatable.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,48 @@ def test_datatable():
210210
)
211211
result = pytester.runpytest("-s")
212212
result.assert_outcomes(passed=1)
213+
214+
215+
def test_datatable_step_argument_is_reserved_and_cannot_be_used(pytester):
216+
pytester.makefile(
217+
".feature",
218+
reserved_datatable_arg=textwrap.dedent(
219+
"""\
220+
Feature: Reserved datatable argument
221+
222+
Scenario: Reserved datatable argument
223+
Given this step has a {datatable} argument
224+
Then the test fails
225+
"""
226+
),
227+
)
228+
229+
pytester.makepyfile(
230+
textwrap.dedent(
231+
"""\
232+
from pytest_bdd import scenario, given, then, parsers
233+
234+
@scenario("reserved_datatable_arg.feature", "Reserved datatable argument")
235+
def test_datatable():
236+
pass
237+
238+
239+
@given(parsers.parse("this step has a {datatable} argument"))
240+
def _(datatable):
241+
pass
242+
243+
244+
@then("the test fails")
245+
def _():
246+
pass
247+
"""
248+
)
249+
)
250+
251+
result = pytester.runpytest()
252+
result.assert_outcomes(failed=1)
253+
result.stdout.fnmatch_lines(
254+
[
255+
"*Step 'this step has a {datatable} argument' defines argument names that are reserved: 'datatable'. Please use different names.*"
256+
]
257+
)

tests/steps/test_docstring.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,48 @@ def _():
193193
)
194194
result = pytester.runpytest("-s")
195195
result.assert_outcomes(passed=1)
196+
197+
198+
def test_docstring_step_argument_is_reserved_and_cannot_be_used(pytester):
199+
pytester.makefile(
200+
".feature",
201+
reserved_docstring_arg=textwrap.dedent(
202+
"""\
203+
Feature: Reserved docstring argument
204+
205+
Scenario: Reserved docstring argument
206+
Given this step has a {docstring} argument
207+
Then the test fails
208+
"""
209+
),
210+
)
211+
212+
pytester.makepyfile(
213+
textwrap.dedent(
214+
"""\
215+
from pytest_bdd import scenario, given, then, parsers
216+
217+
@scenario("reserved_docstring_arg.feature", "Reserved docstring argument")
218+
def test_docstring():
219+
pass
220+
221+
222+
@given(parsers.parse("this step has a {docstring} argument"))
223+
def _(docstring):
224+
pass
225+
226+
227+
@then("the test fails")
228+
def _():
229+
pass
230+
"""
231+
)
232+
)
233+
234+
result = pytester.runpytest()
235+
result.assert_outcomes(failed=1)
236+
result.stdout.fnmatch_lines(
237+
[
238+
"*Step 'this step has a {docstring} argument' defines argument names that are reserved: 'docstring'. Please use different names.*"
239+
]
240+
)

0 commit comments

Comments
 (0)