Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/pytest-dev/pytest-bdd/discussions/545>`_

Changed
+++++++
Expand Down
4 changes: 2 additions & 2 deletions src/pytest_bdd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
69 changes: 69 additions & 0 deletions src/pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<scenario name>`` 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)
86 changes: 86 additions & 0 deletions tests/feature/test_scenarios_class.py
Original file line number Diff line number Diff line change
@@ -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)