Add stacklevel param to the steps, implement a test that makes use of it

This commit is contained in:
Alessio Bogon 2022-07-29 16:23:31 +02:00
parent 865a897bcf
commit a16d246842
5 changed files with 172 additions and 14 deletions

View File

@ -2,6 +2,6 @@
from __future__ import annotations
from pytest_bdd.scenario import scenario, scenarios
from pytest_bdd.steps import given, then, when
from pytest_bdd.steps import given, step, then, when
__all__ = ["given", "when", "then", "scenario", "scenarios"]
__all__ = ["given", "when", "step", "then", "scenario", "scenarios"]

View File

@ -52,7 +52,7 @@ def find_fixturedefs_for_step(step: Step, fixturemanager: FixtureManager, nodeid
if step_func_context is None:
continue
if step_func_context.type != step.type:
if step_func_context.type is not None and step_func_context.type != step.type:
continue
match = step_func_context.parser.is_matching(step.name)
@ -331,6 +331,7 @@ def get_python_name_generator(name: str) -> Iterable[str]:
suffix = f"_{index}"
# TODO: add stacklevel and test it
def scenarios(*feature_paths: str, **kwargs: Any) -> None:
"""Parse features from the paths and put all found scenarios in the caller module.

View File

@ -70,13 +70,14 @@ class StepFunctionContext:
def get_step_fixture_name(step: Step) -> str:
"""Get step fixture name"""
return f"{StepNamePrefix.step_impl}_{step.type}_{step.name}"
return f"{StepNamePrefix.step_impl.value}_{step.type}_{step.name}"
def given(
name: str | StepParser,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
stacklevel: int = 1, # TODO: Add it to the docstring
) -> Callable:
"""Given step decorator.
@ -87,11 +88,14 @@ def given(
:return: Decorator function for the step.
"""
return step(name, GIVEN, converters=converters, target_fixture=target_fixture)
return step(name, GIVEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel)
def when(
name: str | StepParser, converters: dict[str, Callable] | None = None, target_fixture: str | None = None
name: str | StepParser,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
stacklevel: int = 1, # TODO: Add it to the docstring
) -> Callable:
"""When step decorator.
@ -102,11 +106,14 @@ def when(
:return: Decorator function for the step.
"""
return step(name, WHEN, converters=converters, target_fixture=target_fixture)
return step(name, WHEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel)
def then(
name: str | StepParser, converters: dict[str, Callable] | None = None, target_fixture: str | None = None
name: str | StepParser,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
stacklevel: int = 1, # TODO: Add it to the docstring
) -> Callable:
"""Then step decorator.
@ -117,7 +124,7 @@ def then(
:return: Decorator function for the step.
"""
return step(name, THEN, converters=converters, target_fixture=target_fixture)
return step(name, THEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel)
def find_unique_name(name: str, seen: Iterable[str]) -> str:
@ -145,6 +152,7 @@ def step(
type_: Literal["given", "when", "then"] | None = None,
converters: dict[str, Callable] | None = None,
target_fixture: str | None = None,
stacklevel: int = 1, # TODO: Add it to the docstring
) -> Callable[[TCallable], TCallable]:
"""Generic step decorator.
@ -179,9 +187,9 @@ def step(
step_function_marker._pytest_bdd_step_context = context
caller_locals = get_caller_module_locals()
caller_locals = get_caller_module_locals(stacklevel=stacklevel)
fixture_step_name = find_unique_name(
f"{StepNamePrefix.step_def}_{type_ or '*'}_{parser.name}", seen=caller_locals.keys()
f"{StepNamePrefix.step_def.value}_{type_ or '*'}_{parser.name}", seen=caller_locals.keys()
)
caller_locals[fixture_step_name] = pytest.fixture(name=fixture_step_name)(step_function_marker)
return func

View File

@ -28,16 +28,18 @@ def get_args(func: Callable) -> list[str]:
:rtype: list
"""
params = signature(func).parameters.values()
return [param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD]
return [
param.name for param in params if param.kind == param.POSITIONAL_OR_KEYWORD and param.default is param.empty
]
def get_caller_module_locals(depth: int = 2) -> dict[str, Any]:
def get_caller_module_locals(stacklevel: int = 1) -> dict[str, Any]:
"""Get the caller module locals dictionary.
We use sys._getframe instead of inspect.stack(0) because the latter is way slower, since it iterates over
all the frames in the stack.
"""
return _getframe(depth).f_locals
return _getframe(stacklevel + 1).f_locals
def get_caller_module_path(depth: int = 2) -> str:

View File

@ -97,3 +97,150 @@ def test_step_functions_same_parser(testdir):
[first_given, second_given] = collect_dumped_objects(result)
assert first_given == ("str",)
assert second_given == ("re", "testfoo")
def test_user_implements_a_step_generator(testdir):
testdir.makefile(
".feature",
user_step_generator=textwrap.dedent(
"""\
Feature: A feature
Scenario: A scenario
Given I have 10 EUR
And the wallet is verified
And I have a wallet
When I pay 1 EUR
Then I should have 9 EUR in my wallet
"""
),
)
testdir.makepyfile(
textwrap.dedent(
"""\
import re
from dataclasses import dataclass, fields
import pytest
from pytest_bdd import given, when, then, scenarios, parsers
from pytest_bdd.utils import dump_obj
@dataclass
class Wallet:
verified: bool
amount_eur: int
amount_usd: int
amount_gbp: int
amount_jpy: int
def pay(self, amount: int, currency: str) -> None:
if not self.verified:
raise ValueError("Wallet account is not verified")
currency = currency.lower()
field = f"amount_{currency}"
setattr(self, field, getattr(self, field) - amount)
@pytest.fixture
def wallet__verified():
return False
@pytest.fixture
def wallet__amount_eur():
return 0
@pytest.fixture
def wallet__amount_usd():
return 0
@pytest.fixture
def wallet__amount_gbp():
return 0
@pytest.fixture
def wallet__amount_jpy():
return 0
@pytest.fixture()
def wallet(
wallet__verified,
wallet__amount_eur,
wallet__amount_usd,
wallet__amount_gbp,
wallet__amount_jpy,
):
return Wallet(
verified=wallet__verified,
amount_eur=wallet__amount_eur,
amount_usd=wallet__amount_usd,
amount_gbp=wallet__amount_gbp,
amount_jpy=wallet__amount_jpy,
)
def wallet_steps_generator(model_name="wallet"):
@given("I have a wallet", target_fixture=model_name, stacklevel=2)
def _(wallet):
return wallet
@given(
parsers.re(r"the wallet is (?P<negation>not)?verified"),
target_fixture=f"{model_name}__verified",
stacklevel=2,
)
def _(negation: str):
if negation:
return False
return True
# Generate steps for currency fields:
for field in fields(Wallet):
match = re.fullmatch(r"amount_(?P<currency>[a-z]{3})", field.name)
if not match:
continue
currency = match["currency"]
@given(
parsers.parse(f"I have {{value:d}} {currency.upper()}"),
target_fixture=f"{model_name}__amount_{currency}",
stacklevel=2,
)
def _(value: int, _currency=currency) -> int:
dump_obj(f"given {value} {_currency.upper()}")
return value
@when(
parsers.parse(f"I pay {{value:d}} {currency.upper()}"),
stacklevel=2,
)
def _(wallet: Wallet, value: int, _currency=currency) -> None:
dump_obj(f"pay {value} {_currency.upper()}")
wallet.pay(value, _currency)
@then(
parsers.parse(f"I should have {{value:d}} {currency.upper()} in my wallet"),
stacklevel=2,
)
def _(wallet: Wallet, value: int, _currency=currency) -> None:
dump_obj(f"assert {value} {_currency.upper()}")
assert getattr(wallet, f"amount_{_currency}") == value
wallet_steps_generator()
scenarios("user_step_generator.feature")
"""
)
)
result = testdir.runpytest("-s")
result.assert_outcomes(passed=1)
[given, pay, assert_] = collect_dumped_objects(result)
assert given == "given 10 EUR"
assert pay == "pay 1 EUR"
assert assert_ == "assert 9 EUR"