Skip to content

Commit 55bd49d

Browse files
committed
Implement multiple example tables
1 parent bff1995 commit 55bd49d

File tree

6 files changed

+110
-79
lines changed

6 files changed

+110
-79
lines changed

src/pytest_bdd/gherkin_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
104104
@dataclass
105105
class ExamplesTable:
106106
location: Location
107+
tags: list[Tag]
107108
name: str | None = None
108109
table_header: Row | None = None
109110
table_body: list[Row] | None = field(default_factory=list)
110-
tags: list[str] = field(default_factory=set)
111111

112112
@classmethod
113113
def from_dict(cls, data: dict[str, Any]) -> Self:

src/pytest_bdd/parser.py

Lines changed: 31 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
STEP_PARAM_RE = re.compile(r"<(.+?)>")
2323

2424

25+
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
26+
"""Extract tag names from tag data.
27+
28+
Args:
29+
tag_data (List[dict]): The tag data to extract names from.
30+
31+
Returns:
32+
set[str]: A set of tag names.
33+
"""
34+
return {tag.name.lstrip("@") for tag in tag_data}
35+
36+
2537
@dataclass(eq=False)
2638
class Feature:
2739
"""Represents a feature parsed from a feature file.
@@ -64,6 +76,7 @@ class Examples:
6476
name: str | None = None
6577
example_params: list[str] = field(default_factory=list)
6678
examples: list[Sequence[str]] = field(default_factory=list)
79+
tags: set[str] = field(default_factory=set)
6780

6881
def set_param_names(self, keys: Iterable[str]) -> None:
6982
"""Set the parameter names for the examples.
@@ -144,20 +157,19 @@ def steps(self) -> list[Step]:
144157
"""
145158
return (self.feature.background.steps if self.feature.background else []) + self._steps
146159

147-
def render(self, context: Mapping[str, Any]) -> list[Scenario]:
160+
def render(self, context: Mapping[str, Any]) -> Scenario:
148161
"""Render the scenario with the given context.
149162
150163
Args:
151164
context (Mapping[str, Any]): The context for rendering steps.
152165
153166
Returns:
154-
list[Scenario]: A list of Scenario objects with steps rendered based on the context.
167+
Scenario: A Scenario object with steps rendered based on the context.
155168
"""
156-
scenarios = []
157-
base_context = context or {}
158-
base_steps = [
169+
background_steps = self.feature.background.steps if self.feature.background else []
170+
scenario_steps = [
159171
Step(
160-
name=step.render(base_context),
172+
name=step.render(context),
161173
type=step.type,
162174
indent=step.indent,
163175
line_number=step.line_number,
@@ -167,52 +179,16 @@ def render(self, context: Mapping[str, Any]) -> list[Scenario]:
167179
)
168180
for step in self._steps
169181
]
170-
background_steps = self.feature.background.steps if self.feature.background else []
171-
172-
if not self.examples:
173-
# Render a single scenario without examples
174-
scenarios.append(
175-
Scenario(
176-
feature=self.feature,
177-
keyword=self.keyword,
178-
name=self.name,
179-
line_number=self.line_number,
180-
steps=background_steps + base_steps,
181-
tags=self.tags,
182-
description=self.description,
183-
)
184-
)
185-
else:
186-
# Render multiple scenarios with each example context
187-
for examples in self.examples:
188-
for example_context in examples.as_contexts():
189-
full_context = {**base_context, **example_context}
190-
example_steps = [
191-
Step(
192-
name=step.render(full_context),
193-
type=step.type,
194-
indent=step.indent,
195-
line_number=step.line_number,
196-
keyword=step.keyword,
197-
datatable=step.datatable,
198-
docstring=step.docstring,
199-
)
200-
for step in self._steps
201-
]
202-
example_tags = self.tags.union(examples.tags if hasattr(examples, "tags") else set())
203-
scenarios.append(
204-
Scenario(
205-
feature=self.feature,
206-
keyword=self.keyword,
207-
name=self.name,
208-
line_number=self.line_number,
209-
steps=background_steps + example_steps,
210-
tags=example_tags,
211-
description=self.description,
212-
)
213-
)
214-
215-
return scenarios
182+
steps = background_steps + scenario_steps
183+
return Scenario(
184+
feature=self.feature,
185+
keyword=self.keyword,
186+
name=self.name,
187+
line_number=self.line_number,
188+
steps=steps,
189+
tags=self.tags,
190+
description=self.description,
191+
)
216192

217193

218194
@dataclass(eq=False)
@@ -364,18 +340,6 @@ def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
364340
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
365341
self.encoding = encoding
366342

367-
@staticmethod
368-
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
369-
"""Extract tag names from tag data.
370-
371-
Args:
372-
tag_data (List[dict]): The tag data to extract names from.
373-
374-
Returns:
375-
set[str]: A set of tag names.
376-
"""
377-
return {tag.name.lstrip("@") for tag in tag_data}
378-
379343
def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
380344
"""Parse a list of step data into Step objects.
381345
@@ -432,7 +396,7 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
432396
name=scenario_data.name,
433397
line_number=scenario_data.location.line,
434398
templated=templated,
435-
tags=self.get_tag_names(scenario_data.tags),
399+
tags=get_tag_names(scenario_data.tags),
436400
description=textwrap.dedent(scenario_data.description),
437401
)
438402
for step in self.parse_steps(scenario_data.steps):
@@ -443,6 +407,7 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
443407
examples = Examples(
444408
line_number=example_data.location.line,
445409
name=example_data.name,
410+
tags=get_tag_names(example_data.tags),
446411
)
447412
if example_data.table_header is not None:
448413
param_names = [cell.value for cell in example_data.table_header.cells]
@@ -482,7 +447,7 @@ def parse(self) -> Feature:
482447
filename=self.abs_filename,
483448
rel_filename=self.rel_filename,
484449
name=feature_data.name,
485-
tags=self.get_tag_names(feature_data.tags),
450+
tags=get_tag_names(feature_data.tags),
486451
background=None,
487452
line_number=feature_data.location.line,
488453
description=textwrap.dedent(feature_data.description),

src/pytest_bdd/scenario.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
import pytest
2424
from _pytest.fixtures import FixtureDef, FixtureManager, FixtureRequest, call_fixture_func
25+
from pytest import warns, PytestUnknownMarkWarning
2526
from typing_extensions import ParamSpec
2627

2728
from . import exceptions
@@ -276,9 +277,8 @@ def decorator(*args: Callable[P, T]) -> Callable[P, T]:
276277
@pytest.mark.usefixtures(*func_args)
277278
def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str]) -> Any:
278279
__tracebackhide__ = True
279-
rendered_scenarios = templated_scenario.render(_pytest_bdd_example)
280-
for scenario in rendered_scenarios:
281-
_execute_scenario(feature, scenario, request)
280+
scenario = templated_scenario.render(_pytest_bdd_example)
281+
_execute_scenario(feature, scenario, request)
282282
fixture_values = [request.getfixturevalue(arg) for arg in func_args]
283283
return fn(*fixture_values)
284284

@@ -304,14 +304,24 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
304304
def collect_example_parametrizations(
305305
templated_scenario: ScenarioTemplate,
306306
) -> list[ParameterSet] | None:
307-
if not templated_scenario.examples:
308-
return None
309-
contexts = []
310-
for _examples in templated_scenario.examples:
311-
contexts.extend(_examples.as_contexts())
312-
if not contexts:
313-
return None
314-
return [pytest.param(context, id="-".join(context.values())) for context in contexts]
307+
parametrizations = []
308+
has_multiple_examples = len(templated_scenario.examples) > 1
309+
310+
for example_id, examples in enumerate(templated_scenario.examples):
311+
with warns(PytestUnknownMarkWarning, match=r"Unknown pytest\.mark\.tag"):
312+
example_marks = [pytest.mark.tag(tag) for tag in examples.tags]
313+
for context in examples.as_contexts() or [{}]:
314+
test_id = "-".join((str(example_id), *context.values())) if has_multiple_examples else "-".join(
315+
context.values())
316+
parametrizations.append(
317+
pytest.param(
318+
context,
319+
id=test_id,
320+
marks=example_marks,
321+
),
322+
)
323+
324+
return parametrizations or None
315325

316326

317327
def scenario(

tests/feature/test_outline.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,61 @@ def test_outline(request):
7979
# fmt: on
8080

8181

82+
def test_multiple_outlined(pytester):
83+
pytester.makefile(
84+
".feature",
85+
outline=textwrap.dedent(
86+
"""\
87+
Feature: Outline
88+
Scenario Outline: Outlined given, when, thens with multiple examples tables
89+
Given there are <start> cucumbers
90+
When I eat <eat> cucumbers
91+
Then I should have <left> cucumbers
92+
93+
@positive
94+
Examples: Positive result
95+
| start | eat | left |
96+
| 12 | 5 | 7 |
97+
| 5 | 4 | 1 |
98+
99+
@negative
100+
Examples: Negative result
101+
| start | eat | left |
102+
| 3 | 9 | -6 |
103+
| 1 | 4 | -3 |
104+
"""
105+
),
106+
)
107+
108+
pytester.makeconftest(textwrap.dedent(STEPS))
109+
110+
pytester.makepyfile(
111+
textwrap.dedent(
112+
"""\
113+
from pytest_bdd import scenario
114+
115+
@scenario(
116+
"outline.feature",
117+
"Outlined given, when, thens with multiple examples tables",
118+
)
119+
def test_outline(request):
120+
pass
121+
122+
"""
123+
)
124+
)
125+
result = pytester.runpytest("-s")
126+
result.assert_outcomes(passed=4)
127+
# fmt: off
128+
assert collect_dumped_objects(result) == [
129+
12, 5.0, "7",
130+
5, 4.0, "1",
131+
3, 9.0, "-6",
132+
1, 4.0, "-3",
133+
]
134+
# fmt: on
135+
136+
82137
def test_unused_params(pytester):
83138
"""Test parametrized scenario when the test function lacks parameters."""
84139

tests/generation/test_generate_missing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def test_missing_steps():
8484
['Step Given "I have a foobar" is not defined in the background of the feature "Missing code generation" *']
8585
)
8686

87-
result.stdout.fnmatch_lines(["Please place the code above to the test file(s):"])
87+
result.stdout.fnmatch_lines(["Please place the code above into the test file(s):"])
8888

8989

9090
def test_generate_missing_with_step_parsers(pytester):

tests/parser/test_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def test_parser():
163163
ExamplesTable(
164164
location=Location(column=5, line=26),
165165
name="",
166+
tags=[],
166167
table_header=Row(
167168
id="11",
168169
location=Location(column=7, line=27),

0 commit comments

Comments
 (0)