Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework scenario execution to plugins #33

Merged
merged 1 commit into from
Apr 20, 2022
Merged
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: 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
35 changes: 24 additions & 11 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 @@ -902,18 +914,15 @@ Note that if you use pytest `--strict` option, all bdd tags mentioned in the fea
names, eg starts with a non-number, underscore alphanumeric, etc. That way you can safely use tags for tests filtering.

You can customize how tags are converted to pytest marks by implementing the
``pytest_bdd_apply_tag`` hook and returning ``True`` from it:
``pytest_bdd_convert_tag_to_marks`` hook and returning list of resulting marks from it:

.. code-block:: python

def pytest_bdd_apply_tag(tag, function):
def pytest_bdd_convert_tag_to_marks(feature, scenario, tag):
if tag == 'todo':
marker = pytest.mark.skip(reason="Not implemented yet")
marker(function)
return True
else:
# Fall back to the default behavior of pytest-bdd
return None
return [marker]


Test setup
----------
Expand Down Expand Up @@ -1192,12 +1201,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
7 changes: 0 additions & 7 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 @@ -133,14 +132,8 @@ def line_number(self):
def description(self):
return dedent(self.gherkin_ast.gherkin_document.feature.description)

@property
def registry(self):
return self.gherkin_ast.registry

@property
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