Skip to content

Commit c70f526

Browse files
authored
Merge pull request #743 from pytest-dev/render-docstrings-and-datatables-with-example-params
Render docstrings and datatable cells with example table entries
2 parents 1e5595b + 3f42ef3 commit c70f526

File tree

4 files changed

+182
-27
lines changed

4 files changed

+182
-27
lines changed

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Removed
2424
Fixed
2525
+++++
2626
* Fixed an issue with the upcoming pytest release related to the use of ``@pytest.mark.usefixtures`` with an empty list.
27+
* Render template variables in docstrings and datatable cells with example table entries, as we already do for steps definitions.
2728

2829
Security
2930
++++++++

README.rst

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,58 @@ Example:
513513
def should_have_left_cucumbers(cucumbers, left):
514514
assert cucumbers["start"] - cucumbers["eat"] == left
515515
516+
517+
Example parameters from example tables can not only be used in steps, but also embedded directly within docstrings and datatables, allowing for dynamic substitution.
518+
This provides added flexibility for scenarios that require complex setups or validations.
519+
520+
Example:
521+
522+
.. code-block:: gherkin
523+
524+
# content of docstring_and_datatable_with_params.feature
525+
526+
Feature: Docstring and Datatable with example parameters
527+
Scenario Outline: Using parameters in docstrings and datatables
528+
Given the following configuration:
529+
"""
530+
username: <username>
531+
password: <password>
532+
"""
533+
When the user logs in
534+
Then the response should contain:
535+
| field | value |
536+
| username | <username> |
537+
| logged_in | true |
538+
539+
Examples:
540+
| username | password |
541+
| user1 | pass123 |
542+
| user2 | 123secure |
543+
544+
.. code-block:: python
545+
546+
from pytest_bdd import scenarios, given, when, then
547+
import json
548+
549+
# Load scenarios from the feature file
550+
scenarios("docstring_and_datatable_with_params.feature")
551+
552+
553+
@given("the following configuration:")
554+
def given_user_config(docstring):
555+
print(docstring)
556+
557+
558+
@when("the user logs in")
559+
def user_logs_in(logged_in):
560+
logged_in = True
561+
562+
563+
@then("the response should contain:")
564+
def response_should_contain(datatable):
565+
assert datatable[1][1] in ["user1", "user2"]
566+
567+
516568
Rules
517569
-----
518570

src/pytest_bdd/parser.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import copy
34
import os.path
45
import re
56
import textwrap
@@ -20,7 +21,28 @@
2021
from .gherkin_parser import get_gherkin_document
2122
from .types import STEP_TYPE_BY_PARSER_KEYWORD
2223

23-
STEP_PARAM_RE = re.compile(r"<(.+?)>")
24+
PARAM_RE = re.compile(r"<(.+?)>")
25+
26+
27+
def render_string(input_string: str, render_context: Mapping[str, object]) -> str:
28+
"""
29+
Render the string with the given context,
30+
but avoid replacing text inside angle brackets if context is missing.
31+
32+
Args:
33+
input_string (str): The string for which to render/replace params.
34+
render_context (Mapping[str, object]): The context for rendering the string.
35+
36+
Returns:
37+
str: The rendered string with parameters replaced only if they exist in the context.
38+
"""
39+
40+
def replacer(m: re.Match) -> str:
41+
varname = m.group(1)
42+
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
43+
return str(render_context.get(varname, f"<{varname}>"))
44+
45+
return PARAM_RE.sub(replacer, input_string)
2446

2547

2648
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
@@ -189,25 +211,25 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
189211
Returns:
190212
Scenario: A Scenario object with steps rendered based on the context.
191213
"""
214+
base_steps = self.all_background_steps + self._steps
192215
scenario_steps = [
193216
Step(
194-
name=step.render(context),
217+
name=render_string(step.name, context),
195218
type=step.type,
196219
indent=step.indent,
197220
line_number=step.line_number,
198221
keyword=step.keyword,
199-
datatable=step.datatable,
200-
docstring=step.docstring,
222+
datatable=step.render_datatable(step.datatable, context) if step.datatable else None,
223+
docstring=render_string(step.docstring, context) if step.docstring else None,
201224
)
202-
for step in self._steps
225+
for step in base_steps
203226
]
204-
steps = self.all_background_steps + scenario_steps
205227
return Scenario(
206228
feature=self.feature,
207229
keyword=self.keyword,
208-
name=self.name,
230+
name=render_string(self.name, context),
209231
line_number=self.line_number,
210-
steps=steps,
232+
steps=scenario_steps,
211233
tags=self.tags,
212234
description=self.description,
213235
rule=self.rule,
@@ -299,31 +321,24 @@ def __str__(self) -> str:
299321
"""
300322
return f'{self.type.capitalize()} "{self.name}"'
301323

302-
@property
303-
def params(self) -> tuple[str, ...]:
304-
"""Get the parameters in the step name.
305-
306-
Returns:
307-
Tuple[str, ...]: A tuple of parameter names found in the step name.
324+
@staticmethod
325+
def render_datatable(datatable: DataTable, context: Mapping[str, object]) -> DataTable:
308326
"""
309-
return tuple(frozenset(STEP_PARAM_RE.findall(self.name)))
310-
311-
def render(self, context: Mapping[str, Any]) -> str:
312-
"""Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing.
327+
Render the datatable with the given context,
328+
but avoid replacing text inside angle brackets if context is missing.
313329
314330
Args:
315-
context (Mapping[str, Any]): The context for rendering the step name.
331+
datatable (DataTable): The datatable to render.
332+
context (Mapping[str, Any]): The context for rendering the datatable.
316333
317334
Returns:
318-
str: The rendered step name with parameters replaced only if they exist in the context.
335+
datatable (DataTable): The rendered datatable with parameters replaced only if they exist in the context.
319336
"""
320-
321-
def replacer(m: re.Match) -> str:
322-
varname = m.group(1)
323-
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
324-
return str(context.get(varname, f"<{varname}>"))
325-
326-
return STEP_PARAM_RE.sub(replacer, self.name)
337+
rendered_datatable = copy.deepcopy(datatable)
338+
for row in rendered_datatable.rows:
339+
for cell in row.cells:
340+
cell.value = render_string(cell.value, context)
341+
return rendered_datatable
327342

328343

329344
@dataclass(eq=False)

tests/feature/test_scenario.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,93 @@ def _():
199199
result.assert_outcomes(passed=2)
200200

201201

202+
def test_example_params(pytester):
203+
"""Test example params are rendered where necessary:
204+
* Step names
205+
* Docstring
206+
* Datatables
207+
"""
208+
pytester.makefile(
209+
".feature",
210+
example_params='''
211+
Feature: Example params
212+
Background:
213+
Given I have a background <background>
214+
And my background has:
215+
"""
216+
Background <background>
217+
"""
218+
219+
Scenario Outline: Outlined scenario
220+
Given I have a templated <foo>
221+
When I have a templated datatable
222+
| <data> |
223+
| example |
224+
And I have a templated docstring
225+
"""
226+
This is a <doc>
227+
"""
228+
Then pass
229+
230+
Examples:
231+
| background | foo | data | doc |
232+
| parameter | bar | table | string |
233+
''',
234+
)
235+
pytester.makepyfile(
236+
"""
237+
from pytest_bdd import scenarios, given, when, then, parsers
238+
from pytest_bdd.utils import dump_obj
239+
240+
scenarios("example_params.feature")
241+
242+
243+
@given(parsers.parse("I have a background {background}"))
244+
def _(background):
245+
return dump_obj(("background", background))
246+
247+
248+
@given(parsers.parse("I have a templated {foo}"))
249+
def _(foo):
250+
return "foo"
251+
252+
253+
@given("my background has:")
254+
def _(docstring):
255+
return dump_obj(("background_docstring", docstring))
256+
257+
258+
@given("I have a rule table:")
259+
def _(datatable):
260+
return dump_obj(("rule", datatable))
261+
262+
263+
@when("I have a templated datatable")
264+
def _(datatable):
265+
return dump_obj(("datatable", datatable))
266+
267+
268+
@when("I have a templated docstring")
269+
def _(docstring):
270+
return dump_obj(("docstring", docstring))
271+
272+
273+
@then("pass")
274+
def _():
275+
pass
276+
"""
277+
)
278+
result = pytester.runpytest("-s")
279+
result.assert_outcomes(passed=1)
280+
281+
assert collect_dumped_objects(result) == [
282+
("background", "parameter"),
283+
("background_docstring", "Background parameter"),
284+
("datatable", [["table"], ["example"]]),
285+
("docstring", "This is a string"),
286+
]
287+
288+
202289
def test_multilanguage_support(pytester):
203290
"""Test multilanguage support."""
204291
pytester.makefile(

0 commit comments

Comments
 (0)