diff --git a/pytest_bdd/__init__.py b/pytest_bdd/__init__.py index aeaeb74..6827298 100644 --- a/pytest_bdd/__init__.py +++ b/pytest_bdd/__init__.py @@ -1,7 +1,7 @@ """pytest-bdd public API.""" from __future__ import annotations -from pytest_bdd.scenario import scenario, scenarios +from pytest_bdd.scenario import scenario, scenario_class, scenarios from pytest_bdd.steps import given, step, then, when __all__ = ["given", "when", "step", "then", "scenario", "scenarios"] diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 592a274..157627c 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -367,9 +367,54 @@ def scenarios(*feature_paths: str, **kwargs: Any) -> None: for test_name in get_python_name_generator(scenario_name): if test_name not in caller_locals: - # found an unique test name + # found a unique test name caller_locals[test_name] = _scenario break found = True if not found: raise exceptions.NoScenariosFound(abs_feature_paths) + + +# TODO: Test this +def to_camel_case(string: str) -> str: + """Convert a string to camelCase.""" + words = [word.strip() for word in re.split(r"([A-Z][a-z]+)", string)] + return "".join(word.capitalize() for word in words if word.isalpha()) + + +def scenario_class(feature_path: str, cls_name: str | None = None, **kwargs: Any) -> type[object]: + caller_path = get_caller_module_path() + + features_base_dir = kwargs.get("features_base_dir") + if features_base_dir is None: + features_base_dir = get_features_base_dir(caller_path) + + if not os.path.isabs(feature_path): + feature_path = os.path.abspath(os.path.join(features_base_dir, feature_path)) + + base, name = os.path.split(feature_path) + feature = get_feature(base, name, **kwargs) + + if cls_name is None: + cls_name = to_camel_case(feature.name) + + cls = type(cls_name, (), {}) + + seen_test_names = set() + + for scenario_name, _ in feature.scenarios.items(): + + @staticmethod # TODO: We should not require this. + @scenario(feature.filename, scenario_name, **kwargs) + def _test_scenario() -> None: + pass # pragma: no cover + + for test_name in get_python_name_generator(scenario_name): + # add it to the test class + if test_name in seen_test_names: + continue + _test_scenario.__name__ = test_name + setattr(cls, test_name, _test_scenario) + break + + return cls diff --git a/tests/feature/test_scenarios.py b/tests/feature/test_scenarios.py index f5f7958..f258746 100644 --- a/tests/feature/test_scenarios.py +++ b/tests/feature/test_scenarios.py @@ -1,6 +1,8 @@ """Test scenarios shortcut.""" import textwrap +from pytest_bdd.utils import collect_dumped_objects + def test_scenarios(testdir, pytest_params): """Test scenarios shortcut (used together with @scenario for individual test override).""" @@ -73,6 +75,100 @@ def test_scenarios(testdir, pytest_params): result.stdout.fnmatch_lines(["*test_test_scenario_1 *bar!", "PASSED"]) +def test_scenarios_class(testdir): + testdir.makepyfile( + """ + import pytest + from pytest_bdd import given, scenario_class + from pytest_bdd.utils import dump_obj + + @given('I have a bar') + def _(): + dump_obj('bar') + return 'bar' + + + @given('I have a foo') + def _(): + dump_obj('foo') + return 'foo' + + + TestMyFeature = scenario_class("my_feature.feature", name="TestMyFeature") + """ + ) + + testdir.makefile( + "feature", + my_feature="""\ + Feature: Test feature + Scenario: Scenario foo + Given I have a foo + Scenario: Scenario bar + Given I have a bar + """, + ) + + result = testdir.runpytest("-s", "-v", "-o", "console_output_style=classic") + result.assert_outcomes(passed=2) + result.stdout.fnmatch_lines(["*TestMyFeature::test_scenario_bar*", "PASSED"]) + result.stdout.fnmatch_lines(["*TestMyFeature::test_scenario_foo*", "PASSED"]) + objs = collect_dumped_objects(result) + assert objs == ["bar", "foo"] + + +def test_scenarios_class_can_override_generated_tests(testdir): + testdir.makepyfile( + """ + import pytest + from pytest_bdd import given, scenario_class, parsers, scenario + from pytest_bdd.utils import dump_obj + + @given(parsers.parse('I have a {value}')) + def _(value: str) -> str: + dump_obj(f"Given I have a {value}") + return value + + + class TestMyFeature(scenario_class("my_feature.feature")): + # TODO: We should not be required to use @staticmethod here + @staticmethod + # TODO: We should not be required to pass "my_feature.feature" here + @scenario("my_feature.feature", "Scenario foo") + def test_my_scenario_foo(): + dump_obj("overriding scenario foo") + + @pytest.mark.skip(reason="testing marker") + # TODO: We should not be required to use @staticmethod here + @staticmethod + @scenario("my_feature.feature", "Scenario bar") + def test_my_scenario_bar_skipped(): + dump_obj("this should not be executed") + """ + ) + + testdir.makefile( + "feature", + my_feature="""\ + Feature: Test feature + Scenario: Scenario bar + Given I have a bar + Scenario: Scenario foo + Given I have a foo + Scenario: Scenario baz + Given I have a baz + """, + ) + + result = testdir.runpytest("-s", "-v", "-o", "console_output_style=classic") + result.assert_outcomes(passed=2, skipped=1) + result.stdout.fnmatch_lines(["*TestMyFeature::test_my_scenario_foo*", "PASSED"]) + result.stdout.fnmatch_lines(["*TestMyFeature::test_my_scenario_bar_skipped*", "SKIPPED"]) + result.stdout.fnmatch_lines(["*TestMyFeature::test_scenario_baz*", "PASSED"]) + [skip_msgs] = collect_dumped_objects(result) + assert skip_msgs == ["overriding scenario foo", "Given I have a baz"] + + def test_scenarios_none_found(testdir, pytest_params): """Test scenarios shortcut when no scenarios found.""" testpath = testdir.makepyfile(