Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add rule feature #728

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Unreleased
- Dropped support for python 3.8. Supported python versions: 3.9, 3.10, 3.11, 3.12, 3.13.
- Text after the `#` character is no longer stripped from the Scenario and Feature name.
- 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).
- Rule keyword can be used in feature files (see `Rule <https://cucumber.io/docs/gherkin/reference/#rule>`)

8.0.0b2
----------
Expand Down
37 changes: 37 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,43 @@ Example:
def should_have_left_cucumbers(cucumbers, left):
assert cucumbers["start"] - cucumbers["eat"] == left

Rules
-----

In Gherkin, `Rules` allow you to group related scenarios or examples under a shared context.
This is useful when you want to define different conditions or behaviours
for multiple examples that follow a similar structure.
You can use either `Scenario` or `Example` to define individual cases, as they are aliases and function identically.
jsa34 marked this conversation as resolved.
Show resolved Hide resolved

Additionally, **tags** applied to a rule will be automatically applied to all the **examples or scenarios**
under that rule, making it easier to organize and filter tests during execution.

Example:

.. code-block:: gherkin

Feature: Rules and examples

@feature_tag
Rule: A rule for valid cases

@rule_tag
Example: Valid case 1
Given I have a valid input
When I process the input
Then the result should be successful

Rule: A rule for invalid cases
Example: Invalid case
Given I have an invalid input
When I process the input
Then the result should be an error

In Gherkin, `Scenario` and `Example` are interchangeable.
You can use either keyword to define individual test cases.
For example, the previous rule could have been written with the `Scenario` keyword instead of `Example`,
and the behaviour would remain the same.
jsa34 marked this conversation as resolved.
Show resolved Hide resolved


Datatables
----------
Expand Down
43 changes: 31 additions & 12 deletions src/pytest_bdd/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from .compat import getfixturedefs
from .feature import get_features
from .parser import Feature, Rule
from .scenario import inject_fixturedefs_for_step, make_python_docstring, make_python_name, make_string_literal
from .steps import get_step_fixture_name
from .types import STEP_TYPES
Expand All @@ -25,7 +26,7 @@
from _pytest.main import Session
from _pytest.python import Function

from .parser import Feature, ScenarioTemplate, Step
from .parser import ScenarioTemplate, Step
jsa34 marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down Expand Up @@ -88,8 +89,10 @@
for scenario in scenarios:
tw.line()
tw.line(
'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}"'
" in the file {scenario.feature.filename}:{scenario.line_number}".format(scenario=scenario),
(
f'Scenario "{scenario.name}" is not bound to any test in the feature "{scenario.feature.name}" '
f"in the file {scenario.feature.filename}:{scenario.line_number}"
),
red=True,
)

Expand All @@ -100,18 +103,34 @@
tw.line()
if step.scenario is not None:
tw.line(
"""Step {step} is not defined in the scenario "{step.scenario.name}" in the feature"""
""" "{step.scenario.feature.name}" in the file"""
""" {step.scenario.feature.filename}:{step.line_number}""".format(step=step),
(
f'Step {step} is not defined in the scenario "{step.scenario.name}" '
f'in the feature "{step.scenario.feature.name}" in the file '
f"{step.scenario.feature.filename}:{step.line_number}"
),
red=True,
)
elif step.background is not None:
tw.line(
"""Step {step} is not defined in the background of the feature"""
""" "{step.background.feature.name}" in the file"""
""" {step.background.feature.filename}:{step.line_number}""".format(step=step),
red=True,
)
feature_or_rule = step.background.parent
if isinstance(feature_or_rule, Feature):
tw.line(
(
f"Step {step} is not defined in the background of the feature "
f'"{feature_or_rule.name}" in the file '
f"{feature_or_rule.filename}:{step.line_number}"
),
red=True,
)
elif isinstance(feature_or_rule, Rule):
_feature: Feature = feature_or_rule.feature
tw.line(

Check warning on line 126 in src/pytest_bdd/generation.py

View check run for this annotation

Codecov / codecov/patch

src/pytest_bdd/generation.py#L125-L126

Added lines #L125 - L126 were not covered by tests
(
f"Step {step} is not defined in the background of the rule "
f'"{feature_or_rule.name}" in the file '
f"{_feature.filename}:{step.line_number}"
),
red=True,
)
jsa34 marked this conversation as resolved.
Show resolved Hide resolved

if step:
tw.sep("-", red=True)
Expand Down
107 changes: 75 additions & 32 deletions src/pytest_bdd/gherkin_terminal_reporter.py
jsa34 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
from _pytest.reports import TestReport


INDENT_UNIT = " "


def get_indent(level: int) -> str:
"""Get the indentation for a given level."""
return INDENT_UNIT * level


def add_options(parser: Parser) -> None:
group = parser.getgroup("terminal reporting", "reporting", after="general")
group._addoption(
Expand Down Expand Up @@ -56,42 +64,77 @@
# probably passed setup/teardown
return None

if isinstance(word, tuple):
word, word_markup = word
elif rep.passed:
word_markup = {"green": True}
elif rep.failed:
word_markup = {"red": True}
elif rep.skipped:
word_markup = {"yellow": True}
feature_markup = {"blue": True}
scenario_markup = word_markup
# Determine color markup based on test outcome
word_markup = self._get_markup_for_result(report)

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

feature_markup = {"blue": True}
scenario_markup = word_markup

self.ensure_newline()

if self.verbosity == 1:
self.ensure_newline()
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
self._tw.write("\n")
self._tw.write(f" {report.scenario['keyword']}: ", **scenario_markup)
self._tw.write(report.scenario["name"], **scenario_markup)
self._tw.write(" ")
self._tw.write(word, **word_markup)
self._tw.write("\n")
self._print_summary_report(report, word, feature_markup, scenario_markup, word_markup)
elif self.verbosity > 1:
self.ensure_newline()
self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
self._tw.write("\n")
self._tw.write(f" {report.scenario['keyword']}: ", **scenario_markup)
self._tw.write(report.scenario["name"], **scenario_markup)
self._tw.write("\n")
for step in report.scenario["steps"]:
self._tw.write(f" {step['keyword']} {step['name']}\n", **scenario_markup)
self._tw.write(f" {word}", **word_markup)
self._tw.write("\n\n")

self.stats.setdefault(cat, []).append(rep)
self._print_detailed_report(report, word, feature_markup, scenario_markup, word_markup)

self.stats.setdefault(cat, []).append(report)
return None

@staticmethod
def _get_markup_for_result(report: TestReport) -> dict[str, bool]:
"""Get color markup based on test result."""
if report.passed:
return {"green": True}
elif report.failed:
return {"red": True}
elif report.skipped:
return {"yellow": True}
return {}

Check warning on line 95 in src/pytest_bdd/gherkin_terminal_reporter.py

View check run for this annotation

Codecov / codecov/patch

src/pytest_bdd/gherkin_terminal_reporter.py#L94-L95

Added lines #L94 - L95 were not covered by tests

def _print_summary_report(
self, report: TestReport, word: str, feature_markup: dict, scenario_markup: dict, word_markup: dict
) -> None:
"""Print a summary-style Gherkin report for a test."""
base_indent = self._get_indent_for_scenario(report)

self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
self._tw.write("\n")

if "rule" in report.scenario:
self._tw.write(f"{base_indent}{report.scenario['rule']['keyword']}: {report.scenario['rule']['name']}\n")

Check warning on line 108 in src/pytest_bdd/gherkin_terminal_reporter.py

View check run for this annotation

Codecov / codecov/patch

src/pytest_bdd/gherkin_terminal_reporter.py#L108

Added line #L108 was not covered by tests

self._tw.write(f"{base_indent}{get_indent(1)}{report.scenario['keyword']}: ", **scenario_markup)
self._tw.write(report.scenario["name"], **scenario_markup)
self._tw.write(f" {word}\n", **word_markup)

def _print_detailed_report(
self, report: TestReport, word: str, feature_markup: dict, scenario_markup: dict, word_markup: dict
) -> None:
"""Print a detailed Gherkin report for a test."""
base_indent = self._get_indent_for_scenario(report)

self._tw.write(f"{report.scenario['feature']['keyword']}: ", **feature_markup)
self._tw.write(report.scenario["feature"]["name"], **feature_markup)
self._tw.write("\n")

if "rule" in report.scenario:
self._tw.write(f"{base_indent}{report.scenario['rule']['keyword']}: {report.scenario['rule']['name']}\n")

self._tw.write(f"{base_indent}{get_indent(1)}{report.scenario['keyword']}: ", **scenario_markup)
self._tw.write(report.scenario["name"], **scenario_markup)
self._tw.write("\n")

for step in report.scenario["steps"]:
self._tw.write(f"{base_indent}{get_indent(2)}{step['keyword']} {step['name']}\n", **scenario_markup)

self._tw.write(f"{word}\n", **word_markup)
self._tw.write("\n")

@staticmethod
def _get_indent_for_scenario(report: TestReport) -> str:
"""Get the correct indentation based on whether a rule exists."""
return get_indent(2) if "rule" in report.scenario else get_indent(1)
Loading
Loading