diff --git a/CHANGES.rst b/CHANGES.rst index 3b94132cb..f2b3f256f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,7 @@ Unreleased Added +++++ +* Added ``scenarios_class``, which returns a test class instead of injecting the generated tests into the caller module, so the tests are visible to editors and linters and a single scenario can be overridden by subclassing. `#545 `_ Changed +++++++ diff --git a/src/pytest_bdd/__init__.py b/src/pytest_bdd/__init__.py index f5078b973..eb0ca6bf6 100644 --- a/src/pytest_bdd/__init__.py +++ b/src/pytest_bdd/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pytest_bdd.scenario import scenario, scenarios +from pytest_bdd.scenario import scenario, scenarios, scenarios_class from pytest_bdd.steps import given, step, then, when -__all__ = ["given", "when", "step", "then", "scenario", "scenarios"] +__all__ = ["given", "when", "step", "then", "scenario", "scenarios", "scenarios_class"] diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index 531190617..783bd8b98 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -488,3 +488,72 @@ def _scenario() -> None: found = True if not found: raise exceptions.NoScenariosFound(abs_feature_paths) + + +def scenarios_class( + *feature_paths: str, + encoding: str = "utf-8", + features_base_dir: str | None = None, + class_name: str = "GeneratedScenarios", +) -> type: + """Build and return a test class from the scenarios in the given feature files. + + Unlike :func:`scenarios`, which injects the generated tests into the caller + module, this returns a class. The generated tests are then visible to editors + and linters, and a single scenario can be overridden by subclassing:: + + from pytest_bdd import scenarios_class + + TestLogin = scenarios_class("login.feature") + + To override one scenario, subclass the returned class and redefine the matching + method. The method names follow the same ``test_`` convention as + :func:`scenarios`:: + + from pytest_bdd import scenario, scenarios_class + + class TestLogin(scenarios_class("login.feature")): + @scenario("login.feature", "Failed login") + def test_failed_login(self): + ... + + :param feature_paths: Feature file paths to use for scenarios. + :param encoding: Feature file encoding. + :param features_base_dir: Optional base dir for locating feature files. If not + set, it is resolved from the ``bdd_features_base_dir`` ini option, otherwise + relative to the caller path. + :param class_name: Name given to the generated class. + :return: A class whose ``test_*`` methods run the collected scenarios. + """ + caller_path = get_caller_module_path() + + if features_base_dir is None: + features_base_dir = get_features_base_dir(caller_path) + + abs_feature_paths = [] + for path in feature_paths: + if not os.path.isabs(path): + path = os.path.abspath(os.path.join(features_base_dir, path)) + abs_feature_paths.append(path) + + namespace: dict[str, object] = {} + used_names: set[str] = set() + found = False + for feature in get_features(abs_feature_paths): + for scenario_name in feature.scenarios: + test_function = scenario( + feature.filename, scenario_name, encoding=encoding, features_base_dir=features_base_dir + )(lambda: None) + for test_name in get_python_name_generator(scenario_name): + if test_name not in used_names: + used_names.add(test_name) + # Wrap as a staticmethod so pytest does not pass ``self`` to the + # generated wrapper, which only expects the request and example + # fixtures rather than an instance. + namespace[test_name] = staticmethod(test_function) + break + found = True + if not found: + raise exceptions.NoScenariosFound(abs_feature_paths) + + return type(class_name, (), namespace) diff --git a/tests/feature/test_scenarios_class.py b/tests/feature/test_scenarios_class.py new file mode 100644 index 000000000..64889f335 --- /dev/null +++ b/tests/feature/test_scenarios_class.py @@ -0,0 +1,86 @@ +"""Tests for the ``scenarios_class`` class-returning API (Discussion #545).""" + +from __future__ import annotations + +import textwrap + + +def test_scenarios_class_collects_all_scenarios(pytester): + """A class returned by ``scenarios_class`` is collected and runs every scenario.""" + pytester.makeconftest( + """ + from pytest_bdd import given + + @given("I have a bar") + def _(): + return "bar" + """ + ) + features = pytester.mkdir("features") + features.joinpath("test.feature").write_text( + textwrap.dedent( + """ + Feature: Class scenarios + Scenario: First scenario + Given I have a bar + + Scenario: Second scenario + Given I have a bar + """ + ), + encoding="utf-8", + ) + pytester.makepyfile( + """ + from pytest_bdd import scenarios_class + + TestFeature = scenarios_class("features/test.feature") + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines(["*TestFeature::test_first_scenario*", "*TestFeature::test_second_scenario*"]) + + +def test_scenarios_class_subclass_can_override_one_scenario(pytester): + """Subclassing the generated class lets a single scenario be overridden.""" + pytester.makeconftest( + """ + from pytest_bdd import given + + @given("I have a bar") + def _(): + return "bar" + """ + ) + features = pytester.mkdir("features") + features.joinpath("test.feature").write_text( + textwrap.dedent( + """ + Feature: Class scenarios + Scenario: First scenario + Given I have a bar + + Scenario: Second scenario + Given I have a bar + """ + ), + encoding="utf-8", + ) + pytester.makepyfile( + """ + import pytest + from pytest_bdd import scenario, scenarios_class + + class TestFeature(scenarios_class("features/test.feature")): + # Override just the first scenario, e.g. to mark it as expected to fail, + # while the rest keep running from the generated base class. + @staticmethod + @pytest.mark.xfail(reason="known issue") + @scenario("features/test.feature", "First scenario") + def test_first_scenario(): + raise AssertionError("boom") + """ + ) + result = pytester.runpytest("-v") + result.assert_outcomes(passed=1, xfailed=1)