Skip to content

Commit

Permalink
Rework scenario execution to plugins
Browse files Browse the repository at this point in the history
* scenario/scenarios could be used both as decorators and operators
  • Loading branch information
Kostiantyn Goloveshko authored and Kostiantyn Goloveshko committed Apr 20, 2022
1 parent a765488 commit cadf730
Show file tree
Hide file tree
Showing 22 changed files with 572 additions and 589 deletions.
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ repos:
hooks:
- id: pyupgrade
args: ["--py37-plus"]
# TODO: Enable mypy checker when the checks succeed
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.942
hooks:
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Unreleased
- Show pass/fail status per step in Gherkin terminal reporter
- Step definitions could be used independently from keyword
- ``pytest_bdd_apply_tag`` was removed; ``pytest_bdd_convert_tag_to_marks`` was added instead
- Parser switched to original one
- Changes ``scenario`` and ``scenarios`` function/decorator feature registration order. Both could be used as decorators
- Move scenario execution & step matching to hooks

5.0.0
-----
Expand Down
24 changes: 20 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -579,23 +579,35 @@ Note that you can pass multiple paths, and those paths can be either feature fil
scenarios('features', 'other_features/some.feature', 'some_other_features')
But what if you need to manually bind certain scenario, leaving others to be automatically bound?
Just write your scenario in a `normal` way, but ensure you do it `BEFORE` the call of `scenarios` helper.
Just write your scenario in a `normal` way, but ensure you do it `AFTER` the call of `scenarios` helper.


.. code-block:: python
from pytest_bdd import scenario, scenarios
# assume 'features' subfolder is in this file's directory
scenarios('features')
@scenario('features/some.feature', 'Test something')
def test_something():
pass
# assume 'features' subfolder is in this file's directory
scenarios('features')
In the example above `test_something` scenario binding will be kept manual, other scenarios found in the `features`
folder will be bound automatically.

Scenarios registered by `scenario` or `scenarios` are registered once per test module (and re-registered by
latest inclusions, so keep it wisely).

Both `scenario` or `scenarios` could be used as decorators or as operator calls. Also they could be inlined:

.. code-block:: python
from pytest_bdd import scenario, scenarios
test_features = scenarios('features', return_test_decorator=False)
test_specific_scenario = scenario('features/some.feature', 'Test something', return_test_decorator=False)
Scenario outlines
-----------------
Expand Down Expand Up @@ -1192,12 +1204,16 @@ which might be helpful building useful reporting, visualization, etc on top of i

* pytest_bdd_before_scenario(request, feature, scenario) - Called before scenario is executed

* pytest_bdd_run_scenario(request, feature, scenario) - Execution scenario protocol

* pytest_bdd_after_scenario(request, feature, scenario) - Called after scenario is executed
(even if one of steps has failed)

* pytest_bdd_before_step(request, feature, scenario, step, step_func) - Called before step function
is executed and it's arguments evaluated

* pytest_bdd_run_step(request, feature, scenario, step, previous_step): - Execution step protocol

* pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args) - Called before step
function is executed with evaluated arguments

Expand Down
3 changes: 3 additions & 0 deletions pytest_bdd/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Refactor to Enums
import re
from collections import defaultdict

TAG = "tag"
Expand Down Expand Up @@ -44,3 +45,5 @@ class STEP_TYPE:
"*": STEP_TYPE.AND,
},
)
PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*")
31 changes: 20 additions & 11 deletions pytest_bdd/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@

import py
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from mako.lookup import TemplateLookup
from pkg_resources import get_distribution, parse_version

from .const import STEP_TYPES_BY_NORMALIZED_PREFIX
from .model import Feature, Scenario, Step
from .scenario import make_python_docstring, make_python_name, make_string_literal
from .steps import StepHandler
from .utils import make_python_name

if TYPE_CHECKING: # pragma: no cover
from typing import Any
Expand Down Expand Up @@ -190,23 +191,21 @@ def _show_missing_code_main(config: Config, session: Session) -> None:

for item in session.items:

is_legacy_pytest = get_distribution("pytest").parsed_version < parse_version("7.0")

method_name = "prepare" if is_legacy_pytest else "setup"
methodcaller(method_name, item)(item.session._setupstate)

item = cast(Item, item)
# with suppress(AttributeError):
scenario = item.obj.__scenario__
feature = item.obj.__scenario__.feature
item_request: FixtureRequest = item._request
scenario: Scenario = item_request.getfixturevalue("scenario")
feature: Feature = scenario.feature

for i, s in enumerate(scenarios):
if s.id == scenario.id and s.uri == scenario.uri:
scenarios.remove(s)
break

is_legacy_pytest = get_distribution("pytest").parsed_version < parse_version("7.0")

method_name = "prepare" if is_legacy_pytest else "setup"
methodcaller(method_name, item)(item.session._setupstate)

item_request = item._request

previous_step = None
for step in scenario.steps:
try:
Expand All @@ -227,3 +226,13 @@ def _show_missing_code_main(config: Config, session: Session) -> None:

if scenarios or steps:
session.exitstatus = 100


def make_python_docstring(string: str) -> str:
"""Make a python docstring literal out of a given string."""
return '"""{}."""'.format(string.replace('"""', '\\"\\"\\"'))


def make_string_literal(string: str) -> str:
"""Make python string literal out of a given string."""
return "'{}'".format(string.replace("'", "\\'"))
8 changes: 8 additions & 0 deletions pytest_bdd/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ def pytest_bdd_before_scenario(request, feature, scenario):
"""Called before scenario is executed."""


def pytest_bdd_run_scenario(request, feature, scenario):
"""Execution scenario protocol"""


def pytest_bdd_after_scenario(request, feature, scenario):
"""Called after scenario is executed."""

Expand All @@ -20,6 +24,10 @@ def pytest_bdd_before_step(request, feature, scenario, step, step_func):
"""Called before step function is set up."""


def pytest_bdd_run_step(request, feature, scenario, step, previous_step):
"""Execution step protocol"""


def pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args):
"""Called before step function is executed."""

Expand Down
3 changes: 0 additions & 3 deletions pytest_bdd/model/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ def load_scenarios(scenarios_data) -> list[Scenario]:
def load_ast(ast_data) -> AST:
return cast(AST, ASTSchema().load(data=ast_data, unknown="RAISE"))

# region TODO: Deprecated
@property
def name(self) -> str:
return self.gherkin_ast.gherkin_document.feature.name
Expand All @@ -142,5 +141,3 @@ def tag_names(self):
return sorted(
map(lambda tag: tag.name.lstrip(STEP_PREFIXES[TAG]), self.gherkin_ast.gherkin_document.feature.tags)
)

# endregion
63 changes: 13 additions & 50 deletions pytest_bdd/plugin.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
"""Pytest plugin entry point. Used for any fixtures needed."""
from __future__ import annotations

from typing import TYPE_CHECKING, Generator
from contextlib import suppress
from typing import TYPE_CHECKING

import pytest
from _pytest.mark import Mark, MarkDecorator
from _pytest.python import Metafunc

from . import cucumber_json, generation, gherkin_terminal_reporter, given, reporting, steps, then, when
from .model import Feature, Scenario, Step
from . import cucumber_json, generation, gherkin_terminal_reporter, given, steps, then, when
from .reporting import ScenarioReporterPlugin
from .runner import ScenarioRunner
from .steps import StepHandler
from .utils import CONFIG_STACK

if TYPE_CHECKING: # pragma: no cover
from typing import Any, Callable, Collection, Generator
from typing import Collection

from _pytest.config import Config, PytestPluginManager
from _pytest.config.argparsing import Parser
from _pytest.fixtures import FixtureRequest
from _pytest.runner import CallInfo
from pluggy._result import _Result

from .types import Item


def pytest_addhooks(pluginmanager: PytestPluginManager) -> None:
Expand Down Expand Up @@ -65,9 +63,12 @@ def add_bdd_ini(parser: Parser) -> None:
@pytest.mark.trylast
def pytest_configure(config: Config) -> None:
"""Configure all subplugins."""
config.addinivalue_line("markers", "pytest_bdd_scenario: marker to identify pytest_bdd tests")
CONFIG_STACK.append(config)
cucumber_json.configure(config)
gherkin_terminal_reporter.configure(config)
config.pluginmanager.register(ScenarioReporterPlugin())
config.pluginmanager.register(ScenarioRunner())


def pytest_unconfigure(config: Config) -> None:
Expand All @@ -76,47 +77,9 @@ def pytest_unconfigure(config: Config) -> None:
cucumber_json.unconfigure(config)


@pytest.mark.hookwrapper
def pytest_runtest_makereport(item: Item, call: CallInfo) -> Generator[None, _Result, None]:
outcome = yield
reporting.runtest_makereport(item, call, outcome.get_result())


@pytest.mark.tryfirst
def pytest_bdd_before_scenario(request: FixtureRequest, feature: Feature, scenario: Scenario) -> None:
reporting.before_scenario(request, feature, scenario)


@pytest.mark.tryfirst
def pytest_bdd_step_error(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict,
exception: Exception,
) -> None:
reporting.step_error(request, feature, scenario, step, step_func, step_func_args, exception)


@pytest.mark.tryfirst
def pytest_bdd_before_step(
request: FixtureRequest, feature: Feature, scenario: Scenario, step: Step, step_func: Callable
) -> None:
reporting.before_step(request, feature, scenario, step, step_func)


@pytest.mark.tryfirst
def pytest_bdd_after_step(
request: FixtureRequest,
feature: Feature,
scenario: Scenario,
step: Step,
step_func: Callable,
step_func_args: dict[str, Any],
) -> None:
reporting.after_step(request, feature, scenario, step, step_func, step_func_args)
def pytest_generate_tests(metafunc: Metafunc):
with suppress(AttributeError):
metafunc.function.__pytest_bdd_scenario_registry__.parametrize(metafunc)


def pytest_cmdline_main(config: Config) -> int | None:
Expand Down
Loading

0 comments on commit cadf730

Please sign in to comment.