diff --git a/CHANGES.rst b/CHANGES.rst index 7d2b6c4d..4eb8037b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ Changelog Unreleased ---------- - ⚠️ Backwards incompatible: - ``parsers.re`` now does a `fullmatch `_ instead of a partial match. This is to make it work just like the other parsers, since they don't ignore non-matching characters at the end of the string. `#539 `_ - +- Add support for Scenarios and Scenario Outlines to have descriptions. `#600 `_ 6.1.1 ----- diff --git a/src/pytest_bdd/parser.py b/src/pytest_bdd/parser.py index b010a4c4..aa7c4ec7 100644 --- a/src/pytest_bdd/parser.py +++ b/src/pytest_bdd/parser.py @@ -28,6 +28,8 @@ ("But ", None), ] +TYPES_WITH_DESCRIPTIONS = [types.FEATURE, types.SCENARIO, types.SCENARIO_OUTLINE] + if typing.TYPE_CHECKING: from typing import Any, Iterable, Mapping, Match, Sequence @@ -125,7 +127,8 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Featu multiline_step = False stripped_line = line.strip() clean_line = strip_comments(line) - if not clean_line and (not prev_mode or prev_mode not in types.FEATURE): + if not clean_line and (not prev_mode or prev_mode not in TYPES_WITH_DESCRIPTIONS): + # Blank lines are included in feature and scenario descriptions continue mode = get_step_type(clean_line) or mode @@ -142,7 +145,9 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Featu feature.line_number = line_number feature.tags = get_tags(prev_line) elif prev_mode == types.FEATURE: - description.append(clean_line) + # Do not include comments in descriptions + if not stripped_line.startswith("#"): + description.append(clean_line) else: raise exceptions.FeatureError( "Multiple features are not allowed in a single feature file", @@ -157,6 +162,14 @@ def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Featu keyword, parsed_line = parse_line(clean_line) if mode in [types.SCENARIO, types.SCENARIO_OUTLINE]: + # Lines between the scenario declaration + # and the scenario's first step line + # are considered part of the scenario description. + if scenario and not keyword: + # Do not include comments in descriptions + if not stripped_line.startswith("#"): + scenario.add_description_line(clean_line) + continue tags = get_tags(prev_line) scenario = ScenarioTemplate( feature=feature, @@ -215,6 +228,7 @@ class ScenarioTemplate: tags: set[str] = field(default_factory=set) examples: Examples | None = field(default_factory=lambda: Examples()) _steps: list[Step] = field(init=False, default_factory=list) + _description_lines: list[str] = field(init=False, default_factory=list) def add_step(self, step: Step) -> None: step.scenario = self @@ -241,7 +255,27 @@ def render(self, context: Mapping[str, Any]) -> Scenario: for step in self._steps ] steps = background_steps + scenario_steps - return Scenario(feature=self.feature, name=self.name, line_number=self.line_number, steps=steps, tags=self.tags) + return Scenario( + feature=self.feature, + name=self.name, + line_number=self.line_number, + steps=steps, + tags=self.tags, + description=self._description_lines, + ) + + def add_description_line(self, description_line): + """Add a description line to the scenario. + :param str description_line: + """ + self._description_lines.append(description_line) + + @property + def description(self): + """Get the scenario's description. + :return: The scenario description + """ + return "\n".join(self._description_lines) @dataclass @@ -251,6 +285,7 @@ class Scenario: line_number: int steps: list[Step] tags: set[str] = field(default_factory=set) + description: list[str] = field(default_factory=list) @dataclass diff --git a/tests/feature/test_description.py b/tests/feature/test_description.py index 9678bff1..5d0dcb96 100644 --- a/tests/feature/test_description.py +++ b/tests/feature/test_description.py @@ -19,6 +19,10 @@ def test_description(pytester): Some description goes here. Scenario: Description + Also, the scenario can have a description. + + It goes here between the scenario name + and the first step. Given I have a bar """ ), @@ -39,7 +43,7 @@ def test_description(): def _(): return "bar" - def test_scenario_description(): + def test_feature_description(): assert test_description.__scenario__.feature.description == textwrap.dedent( \"\"\"\\ In order to achieve something @@ -49,9 +53,18 @@ def test_scenario_description(): Some description goes here.\"\"\" ) + + def test_scenario_description(): + assert test_description.__scenario__.description == textwrap.dedent( + \"\"\"\\ + Also, the scenario can have a description. + + It goes here between the scenario name + and the first step.\"\"\" + ) """ ) ) result = pytester.runpytest() - result.assert_outcomes(passed=2) + result.assert_outcomes(passed=3)