From f16264eb8e7091cec4960f0c19855d6707073def Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Thu, 18 Jun 2026 23:05:22 +0100 Subject: [PATCH 1/2] fix: report step before first scenario with newer gherkin-official (#779) gherkin-official 31+ no longer raises a parse error when a step keyword appears before the first scenario or background; it folds the line into the feature description. pytest-bdd now re-detects that case after parsing so a FeatureError is raised consistently across supported gherkin-official versions. --- CHANGES.rst | 1 + src/pytest_bdd/gherkin_parser.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 3b94132cb..12f9fb599 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,7 @@ Removed Fixed +++++ +* Restored the ``FeatureError`` for a step placed before the first scenario or background. ``gherkin-official`` 31 and later no longer raise a parse error for this and fold the line into the feature description, so the check is now performed by pytest-bdd to keep the behaviour consistent across supported ``gherkin-official`` versions. `#779 `_ * Made type annotations stronger and removed most of the ``typing.Any`` usages and ``# type: ignore`` annotations. `#658 `_ Security diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index 8a6e4abdd..0c3ad6383 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -56,6 +56,14 @@ ] +# Gherkin-official >= 31 no longer raises a parse error when a step keyword +# appears before the first scenario or background; it folds the line into the +# feature description instead. We re-detect that case so pytest-bdd reports the +# same FeatureError across every supported gherkin-official version (issue #779). +# Like ERROR_PATTERNS above, this matches the English step keywords only. +STEP_KEYWORD_IN_DESCRIPTION = re.compile(r"^(Given|When|Then|And|But)\s") + + @dataclass class Location: column: int @@ -324,9 +332,48 @@ def get_gherkin_document(abs_filename: str, encoding: str = "utf-8") -> GherkinD raise exceptions.GherkinParseError(f"Unknown parsing error: {message}", line, line_content, filename) from e # At this point, the `gherkin_data` should be valid if no exception was raised + _raise_if_step_in_feature_description(gherkin_data, feature_file_text, abs_filename) + return GherkinDocument.from_dict(gherkin_data) +def _raise_if_step_in_feature_description( + gherkin_data: Mapping[str, Any], feature_file_text: str, abs_filename: str +) -> None: + """Raise a FeatureError if a step keyword sits before the first scenario or background. + + Older gherkin-official versions raised a parse error in this case; newer ones + (>= 31) fold the offending line into the feature description instead. We detect + it here so the behaviour stays consistent across gherkin-official versions (#779). + """ + feature = gherkin_data.get("feature") + if not feature: + return + description = feature.get("description") or "" + non_empty_lines = [line for line in description.splitlines() if line.strip()] + if not non_empty_lines: + return + # A step keyword is only misplaced when it opens the description. A keyword that + # appears later is legitimate free-text inside a multi-line description. + first_line = non_empty_lines[0].strip() + if not STEP_KEYWORD_IN_DESCRIPTION.match(first_line): + return + for lineno, file_line in enumerate(feature_file_text.splitlines(), start=1): + if file_line.strip() == first_line: + raise exceptions.FeatureError( + "Step definition outside of a Scenario or a Background.", + lineno, + file_line, + abs_filename, + ) + raise exceptions.FeatureError( + "Step definition outside of a Scenario or a Background.", + feature["location"]["line"], + first_line, + abs_filename, + ) + + def handle_gherkin_parser_error( raw_error: str, line: int, line_content: str, filename: str, original_exception: Exception | None = None ) -> None: From 56df7d95f0c0d2eb45dd02c3d87e41c450a4a570 Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Fri, 19 Jun 2026 20:30:33 +0100 Subject: [PATCH 2/2] fix: detect a misplaced step using the Gherkin dialect keywords (#779) Address review feedback: instead of a hardcoded English regex, reuse the Gherkin dialect keywords for the feature's language so a step placed before the first scenario is reported in any language Gherkin supports. Add a French regression test. --- src/pytest_bdd/gherkin_parser.py | 34 +++++++++++++++++++------- tests/parser/test_errors.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/pytest_bdd/gherkin_parser.py b/src/pytest_bdd/gherkin_parser.py index 0c3ad6383..1455083f2 100644 --- a/src/pytest_bdd/gherkin_parser.py +++ b/src/pytest_bdd/gherkin_parser.py @@ -8,6 +8,7 @@ from dataclasses import dataclass, field from typing import Any +from gherkin.dialect import Dialect # type: ignore from gherkin.errors import CompositeParserException # type: ignore from gherkin.parser import Parser # type: ignore @@ -56,14 +57,6 @@ ] -# Gherkin-official >= 31 no longer raises a parse error when a step keyword -# appears before the first scenario or background; it folds the line into the -# feature description instead. We re-detect that case so pytest-bdd reports the -# same FeatureError across every supported gherkin-official version (issue #779). -# Like ERROR_PATTERNS above, this matches the English step keywords only. -STEP_KEYWORD_IN_DESCRIPTION = re.compile(r"^(Given|When|Then|And|But)\s") - - @dataclass class Location: column: int @@ -337,6 +330,28 @@ def get_gherkin_document(abs_filename: str, encoding: str = "utf-8") -> GherkinD return GherkinDocument.from_dict(gherkin_data) +def _description_step_keywords(language: str) -> tuple[str, ...]: + """Return the step keywords for ``language`` as Gherkin itself defines them. + + Reusing the dialect keywords means the check follows the same logic Gherkin + uses to recognise steps, so it works for every language Gherkin supports + rather than English alone. The generic ``*`` keyword is omitted: it only + opens a step inside a scenario, and matching it against a description would + flag an ordinary bullet list. + """ + dialect = Dialect.for_name(language) + if dialect is None: + return () + keywords = ( + dialect.given_keywords + + dialect.when_keywords + + dialect.then_keywords + + dialect.and_keywords + + dialect.but_keywords + ) + return tuple(keyword for keyword in keywords if keyword.strip() != "*") + + def _raise_if_step_in_feature_description( gherkin_data: Mapping[str, Any], feature_file_text: str, abs_filename: str ) -> None: @@ -356,7 +371,8 @@ def _raise_if_step_in_feature_description( # A step keyword is only misplaced when it opens the description. A keyword that # appears later is legitimate free-text inside a multi-line description. first_line = non_empty_lines[0].strip() - if not STEP_KEYWORD_IN_DESCRIPTION.match(first_line): + step_keywords = _description_step_keywords(feature.get("language", "en")) + if not any(first_line.startswith(keyword) for keyword in step_keywords): return for lineno, file_line in enumerate(feature_file_text.splitlines(), start=1): if file_line.strip() == first_line: diff --git a/tests/parser/test_errors.py b/tests/parser/test_errors.py index 5c616388a..dade2f94f 100644 --- a/tests/parser/test_errors.py +++ b/tests/parser/test_errors.py @@ -72,6 +72,47 @@ def step_inside_scenario(): result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) +def test_step_outside_scenario_or_background_error_localized(pytester): + """A misplaced step is detected regardless of the feature language. + + The detection uses the Gherkin dialect for the file's language, so a step + keyword from a non-English dialect (here French ``Soit``) is reported the + same way as the English one. + """ + features = pytester.mkdir("features") + features.joinpath("test.feature").write_text( + textwrap.dedent( + """\ + # language: fr + Fonctionnalité: Fonctionnalité invalide + Soit une étape hors de tout scénario + + Scénario: Un scénario valide + Soit une étape dans le scénario + """ + ), + encoding="utf-8", + ) + + pytester.makepyfile( + textwrap.dedent( + """ + from pytest_bdd import scenarios, given + + @given("une étape dans le scénario") + def step_inside_scenario(): + pass + + scenarios('features') + """ + ) + ) + + result = pytester.runpytest() + + result.stdout.fnmatch_lines(["*FeatureError: Step definition outside of a Scenario or a Background.*"]) + + def test_multiple_backgrounds_error(pytester): """Test multiple backgrounds in a single feature.""" features = pytester.mkdir("features")