Skip to content

Commit 9b05277

Browse files
committed
Merge branch 'master' into add-rule
# Conflicts: # CHANGES.rst # src/pytest_bdd/parser.py # src/pytest_bdd/scenario.py
2 parents f7d6c6a + f47c6d2 commit 9b05277

File tree

8 files changed

+163
-23
lines changed

8 files changed

+163
-23
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Unreleased
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.
1010
- Rule keyword can be used in feature files (see `Rule <https://cucumber.io/docs/gherkin/reference/#rule>`)
11+
- Multiple example tables supported
12+
- Added filtering by tags against example tables
1113

1214
8.0.0b2
1315
----------

README.rst

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,71 @@ Example:
546546
Then the result should be an error
547547
548548
549+
Scenario Outlines with Multiple Example Tables
550+
----------------------------------------------
551+
552+
In `pytest-bdd`, you can use multiple example tables in a scenario outline to test
553+
different sets of input data under various conditions.
554+
You can define separate `Examples` blocks, each with its own table of data,
555+
and optionally tag them to differentiate between positive, negative, or any other conditions.
556+
557+
Example:
558+
559+
.. code-block:: gherkin
560+
561+
# content of scenario_outline.feature
562+
563+
Feature: Scenario outlines with multiple examples tables
564+
Scenario Outline: Outlined with multiple example tables
565+
Given there are <start> cucumbers
566+
When I eat <eat> cucumbers
567+
Then I should have <left> cucumbers
568+
569+
@positive
570+
Examples: Positive results
571+
| start | eat | left |
572+
| 12 | 5 | 7 |
573+
| 5 | 4 | 1 |
574+
575+
@negative
576+
Examples: Impossible negative results
577+
| start | eat | left |
578+
| 3 | 9 | -6 |
579+
| 1 | 4 | -3 |
580+
581+
.. code-block:: python
582+
583+
from pytest_bdd import scenarios, given, when, then, parsers
584+
585+
586+
scenarios("scenario_outline.feature")
587+
588+
589+
@given(parsers.parse("there are {start:d} cucumbers"), target_fixture="cucumbers")
590+
def given_cucumbers(start):
591+
return {"start": start, "eat": 0}
592+
593+
594+
@when(parsers.parse("I eat {eat:d} cucumbers"))
595+
def eat_cucumbers(cucumbers, eat):
596+
cucumbers["eat"] += eat
597+
598+
599+
@then(parsers.parse("I should have {left:d} cucumbers"))
600+
def should_have_left_cucumbers(cucumbers, left):
601+
assert cucumbers["start"] - cucumbers["eat"] == left
602+
603+
604+
When you filter scenarios by a tag, only the examples associated with that tag will be executed.
605+
This allows you to run a specific subset of your test cases based on the tag.
606+
For example, in the following scenario outline, if you filter by the @positive tag,
607+
only the examples under the "Positive results" table will be executed, and the "Negative results" table will be ignored.
608+
609+
.. code-block:: bash
610+
611+
pytest -k "positive"
612+
613+
549614
Datatables
550615
----------
551616

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
@@ -23,6 +23,18 @@
2323
STEP_PARAM_RE = re.compile(r"<(.+?)>")
2424

2525

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

6982
def set_param_names(self, keys: Iterable[str]) -> None:
7083
"""Set the parameter names for the examples.
@@ -135,7 +148,7 @@ class ScenarioTemplate:
135148
description: str | None = None
136149
tags: set[str] = field(default_factory=set)
137150
_steps: list[Step] = field(init=False, default_factory=list)
138-
examples: Examples | None = field(default_factory=Examples)
151+
examples: list[Examples] = field(default_factory=list[Examples])
139152
rule: Rule | None = None
140153

141154
def add_step(self, step: Step) -> None:
@@ -349,18 +362,6 @@ def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
349362
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
350363
self.encoding = encoding
351364

352-
@staticmethod
353-
def get_tag_names(tag_data: list[GherkinTag]) -> set[str]:
354-
"""Extract tag names from tag data.
355-
356-
Args:
357-
tag_data (List[dict]): The tag data to extract names from.
358-
359-
Returns:
360-
set[str]: A set of tag names.
361-
"""
362-
return {tag.name.lstrip("@") for tag in tag_data}
363-
364365
def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
365366
"""Parse a list of step data into Step objects.
366367
@@ -420,17 +421,19 @@ def parse_scenario(
420421
name=scenario_data.name,
421422
line_number=scenario_data.location.line,
422423
templated=templated,
423-
tags=self.get_tag_names(scenario_data.tags),
424+
tags=get_tag_names(scenario_data.tags),
424425
description=textwrap.dedent(scenario_data.description),
425426
rule=rule,
426427
)
427428
for step in self.parse_steps(scenario_data.steps):
428429
scenario.add_step(step)
429430

431+
# Loop over multiple example tables if they exist
430432
for example_data in scenario_data.examples:
431433
examples = Examples(
432434
line_number=example_data.location.line,
433435
name=example_data.name,
436+
tags=get_tag_names(example_data.tags),
434437
)
435438
if example_data.table_header is not None:
436439
param_names = [cell.value for cell in example_data.table_header.cells]
@@ -439,7 +442,7 @@ def parse_scenario(
439442
for row in example_data.table_body:
440443
values = [cell.value or "" for cell in row.cells]
441444
examples.add_example(values)
442-
scenario.examples = examples
445+
scenario.examples.append(examples)
443446

444447
return scenario
445448

@@ -470,7 +473,7 @@ def parse(self) -> Feature:
470473
filename=self.abs_filename,
471474
rel_filename=self.rel_filename,
472475
name=feature_data.name,
473-
tags=self.get_tag_names(feature_data.tags),
476+
tags=get_tag_names(feature_data.tags),
474477
background=None,
475478
line_number=feature_data.location.line,
476479
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: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +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 templated_scenario.examples is None:
308-
return None
309-
if contexts := list(templated_scenario.examples.as_contexts()):
310-
return [pytest.param(context, id="-".join(context.values())) for context in contexts]
311-
else:
312-
return None
307+
parametrizations = []
308+
309+
for examples in templated_scenario.examples:
310+
tags: set = examples.tags or set()
311+
312+
example_marks = [getattr(pytest.mark, tag) for tag in tags]
313+
314+
for context in examples.as_contexts():
315+
param_id = "-".join(context.values())
316+
parametrizations.append(
317+
pytest.param(
318+
context,
319+
id=param_id,
320+
marks=example_marks,
321+
),
322+
)
323+
324+
return parametrizations or None
313325

314326

315327
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)