Skip to content

Commit f47c6d2

Browse files
authored
Merge pull request #738 from pytest-dev/multi-example-tables
2 parents a01ca25 + d8f9674 commit f47c6d2

File tree

8 files changed

+164
-24
lines changed

8 files changed

+164
-24
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Unreleased
77
- Text after the `#` character is no longer stripped from the Scenario and Feature name.
88
- Gherkin keyword aliases can now be used and correctly reported in json and terminal output (see `Keywords <https://cucumber.io/docs/gherkin/reference/#keywords>` for permitted list).
99
- Added localization support. The language of the feature file can be specified using the `# language: <language>` directive at the beginning of the file.
10+
- Multiple example tables supported
11+
- Added filtering by tags against example tables
1012

1113
8.0.0b2
1214
----------

README.rst

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,71 @@ Example:
514514
assert cucumbers["start"] - cucumbers["eat"] == left
515515
516516
517+
Scenario Outlines with Multiple Example Tables
518+
----------------------------------------------
519+
520+
In `pytest-bdd`, you can use multiple example tables in a scenario outline to test
521+
different sets of input data under various conditions.
522+
You can define separate `Examples` blocks, each with its own table of data,
523+
and optionally tag them to differentiate between positive, negative, or any other conditions.
524+
525+
Example:
526+
527+
.. code-block:: gherkin
528+
529+
# content of scenario_outline.feature
530+
531+
Feature: Scenario outlines with multiple examples tables
532+
Scenario Outline: Outlined with multiple example tables
533+
Given there are <start> cucumbers
534+
When I eat <eat> cucumbers
535+
Then I should have <left> cucumbers
536+
537+
@positive
538+
Examples: Positive results
539+
| start | eat | left |
540+
| 12 | 5 | 7 |
541+
| 5 | 4 | 1 |
542+
543+
@negative
544+
Examples: Impossible negative results
545+
| start | eat | left |
546+
| 3 | 9 | -6 |
547+
| 1 | 4 | -3 |
548+
549+
.. code-block:: python
550+
551+
from pytest_bdd import scenarios, given, when, then, parsers
552+
553+
554+
scenarios("scenario_outline.feature")
555+
556+
557+
@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="cucumbers")
558+
def given_cucumbers(start):
559+
return {"start": start, "eat": 0}
560+
561+
562+
@when(parsers.parse("I eat {eat:d} cucumbers"))
563+
def eat_cucumbers(cucumbers, eat):
564+
cucumbers["eat"] += eat
565+
566+
567+
@then(parsers.parse("I should have {left:d} cucumbers"))
568+
def should_have_left_cucumbers(cucumbers, left):
569+
assert cucumbers["start"] - cucumbers["eat"] == left
570+
571+
572+
When you filter scenarios by a tag, only the examples associated with that tag will be executed.
573+
This allows you to run a specific subset of your test cases based on the tag.
574+
For example, in the following scenario outline, if you filter by the @positive tag,
575+
only the examples under the "Positive results" table will be executed, and the "Negative results" table will be ignored.
576+
577+
.. code-block:: bash
578+
579+
pytest -k "positive"
580+
581+
517582
Datatables
518583
----------
519584

src/pytest_bdd/gherkin_parser.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ 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)
@@ -115,6 +116,7 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
115116
name=data.get("name"),
116117
table_header=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
117118
table_body=[Row.from_dict(row) for row in data.get("tableBody", [])],
119+
tags=[Tag.from_dict(tag) for tag in data["tags"]],
118120
)
119121

120122

src/pytest_bdd/parser.py

Lines changed: 19 additions & 16 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.
@@ -124,7 +137,7 @@ class ScenarioTemplate:
124137
description: str | None = None
125138
tags: set[str] = field(default_factory=set)
126139
_steps: list[Step] = field(init=False, default_factory=list)
127-
examples: Examples | None = field(default_factory=Examples)
140+
examples: list[Examples] = field(default_factory=list[Examples])
128141

129142
def add_step(self, step: Step) -> None:
130143
"""Add a step to the scenario.
@@ -327,18 +340,6 @@ def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
327340
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
328341
self.encoding = encoding
329342

330-
@staticmethod
331-
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
332-
"""Extract tag names from tag data.
333-
334-
Args:
335-
tag_data (List[dict]): The tag data to extract names from.
336-
337-
Returns:
338-
set[str]: A set of tag names.
339-
"""
340-
return {tag.name.lstrip("@") for tag in tag_data}
341-
342343
def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
343344
"""Parse a list of step data into Step objects.
344345
@@ -395,16 +396,18 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
395396
name=scenario_data.name,
396397
line_number=scenario_data.location.line,
397398
templated=templated,
398-
tags=self.get_tag_names(scenario_data.tags),
399+
tags=get_tag_names(scenario_data.tags),
399400
description=textwrap.dedent(scenario_data.description),
400401
)
401402
for step in self.parse_steps(scenario_data.steps):
402403
scenario.add_step(step)
403404

405+
# Loop over multiple example tables if they exist
404406
for example_data in scenario_data.examples:
405407
examples = Examples(
406408
line_number=example_data.location.line,
407409
name=example_data.name,
410+
tags=get_tag_names(example_data.tags),
408411
)
409412
if example_data.table_header is not None:
410413
param_names = [cell.value for cell in example_data.table_header.cells]
@@ -413,7 +416,7 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
413416
for row in example_data.table_body:
414417
values = [cell.value or "" for cell in row.cells]
415418
examples.add_example(values)
416-
scenario.examples = examples
419+
scenario.examples.append(examples)
417420

418421
return scenario
419422

@@ -444,7 +447,7 @@ def parse(self) -> Feature:
444447
filename=self.abs_filename,
445448
rel_filename=self.rel_filename,
446449
name=feature_data.name,
447-
tags=self.get_tag_names(feature_data.tags),
450+
tags=get_tag_names(feature_data.tags),
448451
background=None,
449452
line_number=feature_data.location.line,
450453
description=textwrap.dedent(feature_data.description),

src/pytest_bdd/plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def _pytest_bdd_example() -> dict:
4949
5050
If no outline is used, we just return an empty dict to render
5151
the current template without any actual variable.
52-
Otherwise pytest_bdd will add all the context variables in this fixture
52+
Otherwise, pytest_bdd will add all the context variables in this fixture
5353
from the example definitions in the feature file.
5454
"""
5555
return {}

src/pytest_bdd/scenario.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
289289
example_parametrizations,
290290
)(scenario_wrapper)
291291

292-
for tag in templated_scenario.tags.union(feature.tags):
292+
for tag in templated_scenario.tags | feature.tags:
293293
config = CONFIG_STACK[-1]
294294
config.hook.pytest_bdd_apply_tag(tag=tag, function=scenario_wrapper)
295295

@@ -303,12 +303,24 @@ def scenario_wrapper(request: FixtureRequest, _pytest_bdd_example: dict[str, str
303303
def collect_example_parametrizations(
304304
templated_scenario: ScenarioTemplate,
305305
) -> list[ParameterSet] | None:
306-
if templated_scenario.examples is None:
307-
return None
308-
if contexts := list(templated_scenario.examples.as_contexts()):
309-
return [pytest.param(context, id="-".join(context.values())) for context in contexts]
310-
else:
311-
return None
306+
parametrizations = []
307+
308+
for examples in templated_scenario.examples:
309+
tags: set = examples.tags or set()
310+
311+
example_marks = [getattr(pytest.mark, tag) for tag in tags]
312+
313+
for context in examples.as_contexts():
314+
param_id = "-".join(context.values())
315+
parametrizations.append(
316+
pytest.param(
317+
context,
318+
id=param_id,
319+
marks=example_marks,
320+
),
321+
)
322+
323+
return parametrizations or None
312324

313325

314326
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_multi_example=textwrap.dedent(
86+
"""\
87+
Feature: Outline With Multiple Examples
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 results
95+
| start | eat | left |
96+
| 12 | 5 | 7 |
97+
| 5 | 4 | 1 |
98+
99+
@negative
100+
Examples: Negative results
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 scenarios
114+
115+
scenarios('outline_multi_example.feature')
116+
117+
"""
118+
)
119+
)
120+
result = pytester.runpytest("-s")
121+
result.assert_outcomes(passed=4)
122+
# fmt: off
123+
assert collect_dumped_objects(result) == [
124+
12, 5.0, "7",
125+
5, 4.0, "1",
126+
3, 9.0, "-6",
127+
1, 4.0, "-3",
128+
]
129+
# fmt: on
130+
result = pytester.runpytest("-k", "positive", "-vv")
131+
result.assert_outcomes(passed=2, deselected=2)
132+
133+
result = pytester.runpytest("-k", "positive or negative", "-vv")
134+
result.assert_outcomes(passed=4, deselected=0)
135+
136+
82137
def test_unused_params(pytester):
83138
"""Test parametrized scenario when the test function lacks parameters."""
84139

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)