Skip to content

Commit 8f9df7c

Browse files
authored
Merge pull request #728 from jsa34/add-rule
2 parents f47c6d2 + 3f91a64 commit 8f9df7c

File tree

11 files changed

+341
-40
lines changed

11 files changed

+341
-40
lines changed

CHANGES.rst

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

README.rst

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,38 @@ Example:
513513
def should_have_left_cucumbers(cucumbers, left):
514514
assert cucumbers["start"] - cucumbers["eat"] == left
515515
516+
Rules
517+
-----
518+
519+
In Gherkin, `Rules` allow you to group related scenarios or examples under a shared context.
520+
This is useful when you want to define different conditions or behaviours
521+
for multiple examples that follow a similar structure.
522+
You can use either ``Scenario`` or ``Example`` to define individual cases, as they are aliases and function identically.
523+
524+
Additionally, **tags** applied to a rule will be automatically applied to all the **examples or scenarios**
525+
under that rule, making it easier to organize and filter tests during execution.
526+
527+
Example:
528+
529+
.. code-block:: gherkin
530+
531+
Feature: Rules and examples
532+
533+
@feature_tag
534+
Rule: A rule for valid cases
535+
536+
@rule_tag
537+
Example: Valid case 1
538+
Given I have a valid input
539+
When I process the input
540+
Then the result should be successful
541+
542+
Rule: A rule for invalid cases
543+
Example: Invalid case
544+
Given I have an invalid input
545+
When I process the input
546+
Then the result should be an error
547+
516548
517549
Scenario Outlines with Multiple Example Tables
518550
----------------------------------------------

src/pytest_bdd/generation.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from .compat import getfixturedefs
1313
from .feature import get_features
14+
from .parser import Feature, ScenarioTemplate, Step
1415
from .scenario import inject_fixturedefs_for_step, make_python_docstring, make_python_name, make_string_literal
1516
from .steps import get_step_fixture_name
1617
from .types import STEP_TYPES
@@ -25,7 +26,6 @@
2526
from _pytest.main import Session
2627
from _pytest.python import Function
2728

28-
from .parser import Feature, ScenarioTemplate, Step
2929

3030
template_lookup = TemplateLookup(directories=[os.path.join(os.path.dirname(__file__), "templates")])
3131

@@ -88,8 +88,10 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) ->
8888
for scenario in scenarios:
8989
tw.line()
9090
tw.line(
91-
'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}"'
92-
" in the file {scenario.feature.filename}:{scenario.line_number}".format(scenario=scenario),
91+
(
92+
f'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}" '
93+
f"in the file {scenario.feature.filename}:{scenario.line_number}"
94+
),
9395
red=True,
9496
)
9597

@@ -100,18 +102,16 @@ def print_missing_code(scenarios: list[ScenarioTemplate], steps: list[Step]) ->
100102
tw.line()
101103
if step.scenario is not None:
102104
tw.line(
103-
"""Step {step} is not defined in the scenario "{step.scenario.name}" in the feature"""
104-
""" "{step.scenario.feature.name}" in the file"""
105-
""" {step.scenario.feature.filename}:{step.line_number}""".format(step=step),
105+
(
106+
f'Step {step} is not defined in the scenario "{step.scenario.name}" '
107+
f'in the feature "{step.scenario.feature.name}" in the file '
108+
f"{step.scenario.feature.filename}:{step.line_number}"
109+
),
106110
red=True,
107111
)
108112
elif step.background is not None:
109-
tw.line(
110-
"""Step {step} is not defined in the background of the feature"""
111-
""" "{step.background.feature.name}" in the file"""
112-
""" {step.background.feature.filename}:{step.line_number}""".format(step=step),
113-
red=True,
114-
)
113+
message = f"Background step {step} is not defined."
114+
tw.line(message, red=True)
115115

116116
if step:
117117
tw.sep("-", red=True)

src/pytest_bdd/gherkin_terminal_reporter.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def configure(config: Config) -> None:
4646
class GherkinTerminalReporter(TerminalReporter): # type: ignore
4747
def __init__(self, config: Config) -> None:
4848
super().__init__(config)
49+
self.current_rule = None
4950

5051
def pytest_runtest_logreport(self, report: TestReport) -> Any:
5152
rep = report
@@ -66,16 +67,27 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
6667
word_markup = {"yellow": True}
6768
feature_markup = {"blue": True}
6869
scenario_markup = word_markup
70+
rule_markup = {"purple": True}
6971

7072
if self.verbosity <= 0 or not hasattr(report, "scenario"):
7173
return super().pytest_runtest_logreport(rep)
7274

75+
rule = report.scenario.get("rule")
76+
indent = " " if rule else ""
77+
7378
if self.verbosity == 1:
7479
self.ensure_newline()
7580
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
7681
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
7782
self._tw.write("\n")
78-
self._tw.write(f" {report.scenario['keyword']}: ", **scenario_markup)
83+
84+
if rule and rule["name"] != self.current_rule:
85+
self._tw.write(f" {rule['keyword']}: ", **rule_markup)
86+
self._tw.write(rule["name"], **rule_markup)
87+
self._tw.write("\n")
88+
self.current_rule = rule["name"]
89+
90+
self._tw.write(f"{indent} {report.scenario['keyword']}: ", **scenario_markup)
7991
self._tw.write(report.scenario["name"], **scenario_markup)
8092
self._tw.write(" ")
8193
self._tw.write(word, **word_markup)
@@ -85,12 +97,19 @@ def pytest_runtest_logreport(self, report: TestReport) -> Any:
8597
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
8698
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
8799
self._tw.write("\n")
88-
self._tw.write(f" {report.scenario['keyword']}: ", **scenario_markup)
100+
101+
if rule and rule["name"] != self.current_rule:
102+
self._tw.write(f" {rule['keyword']}: ", **rule_markup)
103+
self._tw.write(rule["name"], **rule_markup)
104+
self._tw.write("\n")
105+
self.current_rule = rule["name"]
106+
107+
self._tw.write(f"{indent} {report.scenario['keyword']}: ", **scenario_markup)
89108
self._tw.write(report.scenario["name"], **scenario_markup)
90109
self._tw.write("\n")
91110
for step in report.scenario["steps"]:
92-
self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup)
93-
self._tw.write(f" {word}", **word_markup)
111+
self._tw.write(f"{indent} {step['keyword']} {step['name']}\n", **scenario_markup)
112+
self._tw.write(f"{indent} {word}", **word_markup)
94113
self._tw.write("\n\n")
95114

96115
self.stats.setdefault(cat, []).append(rep)

src/pytest_bdd/parser.py

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import re
55
import textwrap
66
from collections import OrderedDict
7-
from collections.abc import Iterable, Mapping, Sequence
7+
from collections.abc import Generator, Iterable, Mapping, Sequence
88
from dataclasses import dataclass, field
99
from typing import Any
1010

@@ -13,6 +13,7 @@
1313
from .gherkin_parser import DataTable
1414
from .gherkin_parser import Feature as GherkinFeature
1515
from .gherkin_parser import GherkinDocument
16+
from .gherkin_parser import Rule as GherkinRule
1617
from .gherkin_parser import Scenario as GherkinScenario
1718
from .gherkin_parser import Step as GherkinStep
1819
from .gherkin_parser import Tag as GherkinTag
@@ -113,6 +114,15 @@ def __bool__(self) -> bool:
113114
return bool(self.examples)
114115

115116

117+
@dataclass(eq=False)
118+
class Rule:
119+
keyword: str
120+
name: str
121+
description: str
122+
tags: set[str]
123+
background: Background | None = None
124+
125+
116126
@dataclass(eq=False)
117127
class ScenarioTemplate:
118128
"""Represents a scenario template within a feature.
@@ -127,6 +137,7 @@ class ScenarioTemplate:
127137
tags (set[str]): A set of tags associated with the scenario.
128138
_steps (List[Step]): The list of steps in the scenario (internal use only).
129139
examples (Optional[Examples]): The examples used for parameterization in the scenario.
140+
rule (Optional[Rule]): The rule to which the scenario may belong (None = no rule).
130141
"""
131142

132143
feature: Feature
@@ -138,6 +149,7 @@ class ScenarioTemplate:
138149
tags: set[str] = field(default_factory=set)
139150
_steps: list[Step] = field(init=False, default_factory=list)
140151
examples: list[Examples] = field(default_factory=list[Examples])
152+
rule: Rule | None = None
141153

142154
def add_step(self, step: Step) -> None:
143155
"""Add a step to the scenario.
@@ -148,14 +160,25 @@ def add_step(self, step: Step) -> None:
148160
step.scenario = self
149161
self._steps.append(step)
150162

163+
@property
164+
def all_background_steps(self) -> list[Step]:
165+
steps = []
166+
# Add background steps from the feature
167+
if self.feature.background:
168+
steps.extend(self.feature.background.steps)
169+
if self.rule is not None and self.rule.background is not None:
170+
# Add background steps from the rule
171+
steps.extend(self.rule.background.steps)
172+
return steps
173+
151174
@property
152175
def steps(self) -> list[Step]:
153176
"""Get all steps for the scenario, including background steps.
154177
155178
Returns:
156179
List[Step]: A list of steps, including any background steps from the feature.
157180
"""
158-
return (self.feature.background.steps if self.feature.background else []) + self._steps
181+
return self.all_background_steps + self._steps
159182

160183
def render(self, context: Mapping[str, Any]) -> Scenario:
161184
"""Render the scenario with the given context.
@@ -166,7 +189,6 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
166189
Returns:
167190
Scenario: A Scenario object with steps rendered based on the context.
168191
"""
169-
background_steps = self.feature.background.steps if self.feature.background else []
170192
scenario_steps = [
171193
Step(
172194
name=step.render(context),
@@ -179,7 +201,7 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
179201
)
180202
for step in self._steps
181203
]
182-
steps = background_steps + scenario_steps
204+
steps = self.all_background_steps + scenario_steps
183205
return Scenario(
184206
feature=self.feature,
185207
keyword=self.keyword,
@@ -188,6 +210,7 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
188210
steps=steps,
189211
tags=self.tags,
190212
description=self.description,
213+
rule=self.rule,
191214
)
192215

193216

@@ -212,6 +235,7 @@ class Scenario:
212235
steps: list[Step]
213236
description: str | None = None
214237
tags: set[str] = field(default_factory=set)
238+
rule: Rule | None = None
215239

216240

217241
@dataclass(eq=False)
@@ -307,12 +331,10 @@ class Background:
307331
"""Represents the background steps for a feature.
308332
309333
Attributes:
310-
feature (Feature): The feature to which this background belongs.
311334
line_number (int): The line number where the background starts in the file.
312335
steps (List[Step]): The list of steps in the background.
313336
"""
314337

315-
feature: Feature
316338
line_number: int
317339
steps: list[Step] = field(init=False, default_factory=list)
318340

@@ -379,12 +401,15 @@ def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
379401
)
380402
return steps
381403

382-
def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> ScenarioTemplate:
404+
def parse_scenario(
405+
self, scenario_data: GherkinScenario, feature: Feature, rule: Rule | None = None
406+
) -> ScenarioTemplate:
383407
"""Parse a scenario data dictionary into a ScenarioTemplate object.
384408
385409
Args:
386410
scenario_data (dict): The dictionary containing scenario data.
387411
feature (Feature): The feature to which this scenario belongs.
412+
rule (Optional[Rule]): The rule to which this scenario may belong. (None = no rule)
388413
389414
Returns:
390415
ScenarioTemplate: A ScenarioTemplate object representing the parsed scenario.
@@ -398,6 +423,7 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
398423
templated=templated,
399424
tags=get_tag_names(scenario_data.tags),
400425
description=textwrap.dedent(scenario_data.description),
426+
rule=rule,
401427
)
402428
for step in self.parse_steps(scenario_data.steps):
403429
scenario.add_step(step)
@@ -420,9 +446,8 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
420446

421447
return scenario
422448

423-
def parse_background(self, background_data: GherkinBackground, feature: Feature) -> Background:
449+
def parse_background(self, background_data: GherkinBackground) -> Background:
424450
background = Background(
425-
feature=feature,
426451
line_number=background_data.location.line,
427452
)
428453
background.steps = self.parse_steps(background_data.steps)
@@ -439,6 +464,7 @@ def _parse_feature_file(self) -> GherkinDocument:
439464
return get_gherkin_document(self.abs_filename, self.encoding)
440465

441466
def parse(self) -> Feature:
467+
"""Parse the feature file and return a Feature object with its backgrounds, rules, and scenarios."""
442468
gherkin_doc: GherkinDocument = self._parse_feature_file()
443469
feature_data: GherkinFeature = gherkin_doc.feature
444470
feature = Feature(
@@ -456,9 +482,47 @@ def parse(self) -> Feature:
456482

457483
for child in feature_data.children:
458484
if child.background:
459-
feature.background = self.parse_background(child.background, feature)
485+
feature.background = self.parse_background(child.background)
486+
elif child.rule:
487+
self._parse_and_add_rule(child.rule, feature)
460488
elif child.scenario:
461-
scenario = self.parse_scenario(child.scenario, feature)
462-
feature.scenarios[scenario.name] = scenario
489+
self._parse_and_add_scenario(child.scenario, feature)
463490

464491
return feature
492+
493+
def _parse_and_add_rule(self, rule_data: GherkinRule, feature: Feature) -> None:
494+
"""Parse a rule, including its background and scenarios, and add to the feature."""
495+
background = self._extract_rule_background(rule_data)
496+
497+
rule = Rule(
498+
keyword=rule_data.keyword,
499+
name=rule_data.name,
500+
description=rule_data.description,
501+
tags=get_tag_names(rule_data.tags),
502+
background=background,
503+
)
504+
505+
for scenario in self._extract_rule_scenarios(rule_data, feature, rule):
506+
feature.scenarios[scenario.name] = scenario
507+
508+
def _extract_rule_background(self, rule_data: GherkinRule) -> Background | None:
509+
"""Extract the first background from rule children if it exists."""
510+
for child in rule_data.children:
511+
if child.background:
512+
return self.parse_background(child.background)
513+
return None
514+
515+
def _extract_rule_scenarios(
516+
self, rule_data: GherkinRule, feature: Feature, rule: Rule
517+
) -> Generator[ScenarioTemplate]:
518+
"""Yield each parsed scenario under a rule."""
519+
for child in rule_data.children:
520+
if child.scenario:
521+
yield self.parse_scenario(child.scenario, feature, rule)
522+
523+
def _parse_and_add_scenario(
524+
self, scenario_data: GherkinScenario, feature: Feature, rule: Rule | None = None
525+
) -> None:
526+
"""Parse an individual scenario and add it to the feature's scenarios."""
527+
scenario = self.parse_scenario(scenario_data, feature, rule)
528+
feature.scenarios[scenario.name] = scenario

0 commit comments

Comments
 (0)