From 8fd0fd5440480decc5bab2ea767d88de95562324 Mon Sep 17 00:00:00 2001 From: Matt Austin Date: Sun, 16 Apr 2023 12:30:35 -0400 Subject: [PATCH 1/2] Fix tests/feature/test_cucumber_json.py::test_step_trace This test was failing when run as part of the entire test suite and passing when run on its own. When the first part of the expected[0].id path was changed from test_step_trace0 to test_step_trace1, the results reversed - it passed as part of the entire suite and failed on its own. This behavior was not observed prior to the async changed. I suspect that this is caused by the addition of test_async_steps.py::test_step_trace, which caused this to no longer be the first test with the name `test_step_trace` to run as part of the entire suite. Since there are several other tests with the same name, it seems like the best course here would be to ensure that this test has a unique name so that it is more resilient to ordering. --- tests/feature/test_cucumber_json.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/feature/test_cucumber_json.py b/tests/feature/test_cucumber_json.py index d3c7b931..2ef9340b 100644 --- a/tests/feature/test_cucumber_json.py +++ b/tests/feature/test_cucumber_json.py @@ -29,7 +29,7 @@ def __eq__(self, other: object) -> bool: return isinstance(other, self.type) if self.type else True -def test_step_trace(pytester): +def test_cucumber_json_step_trace(pytester): """Test step trace.""" pytester.makefile( ".ini", @@ -222,7 +222,7 @@ def test_passing_outline(): "name": "Passing outline", }, ], - "id": os.path.join("test_step_trace0", "test.feature"), + "id": os.path.join("test_cucumber_json_step_trace0", "test.feature"), "keyword": "Feature", "line": 2, "name": "One passing scenario, one failing scenario", From c292b9a33d2f0cdaca5dbd664673d92c23bf7b0c Mon Sep 17 00:00:00 2001 From: Matt Austin Date: Sat, 15 Apr 2023 14:33:34 -0400 Subject: [PATCH 2/2] Implement async steps. --- src/pytest_bdd/asyncio.py | 3 + src/pytest_bdd/scenario.py | 80 +++- src/pytest_bdd/steps.py | 80 +++- tests/feature/test_async_steps.py | 595 ++++++++++++++++++++++++++++++ tests/steps/test_common.py | 21 +- 5 files changed, 770 insertions(+), 9 deletions(-) create mode 100644 src/pytest_bdd/asyncio.py create mode 100644 tests/feature/test_async_steps.py diff --git a/src/pytest_bdd/asyncio.py b/src/pytest_bdd/asyncio.py new file mode 100644 index 00000000..5e6ea9f9 --- /dev/null +++ b/src/pytest_bdd/asyncio.py @@ -0,0 +1,3 @@ +from pytest_bdd.steps import async_given, async_then, async_when + +__all__ = ["async_given", "async_when", "async_then"] diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index df7c029c..f7d46dd2 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -12,7 +12,10 @@ """ from __future__ import annotations +import asyncio import contextlib +import functools +import inspect import logging import os import re @@ -34,7 +37,6 @@ from .parser import Feature, Scenario, ScenarioTemplate, Step - logger = logging.getLogger(__name__) @@ -156,7 +158,14 @@ def _execute_step_function( request.config.hook.pytest_bdd_before_step_call(**kw) # Execute the step as if it was a pytest fixture, so that we can allow "yield" statements in it - return_value = call_fixture_func(fixturefunc=context.step_func, request=request, kwargs=kwargs) + step_func = context.step_func + if context.is_async: + if inspect.isasyncgenfunction(context.step_func): + step_func = _wrap_asyncgen(request, context.step_func) + elif inspect.iscoroutinefunction(context.step_func): + step_func = _wrap_coroutine(context.step_func) + + return_value = call_fixture_func(fixturefunc=step_func, request=request, kwargs=kwargs) except Exception as exception: request.config.hook.pytest_bdd_step_error(exception=exception, **kw) raise @@ -167,6 +176,73 @@ def _execute_step_function( request.config.hook.pytest_bdd_after_step(**kw) +def _wrap_asyncgen(request: FixtureRequest, func: Callable) -> Callable: + """Wrapper for an async_generator function. + + This will wrap the function in a synchronized method to return the first + yielded value from the generator. A finalizer will be added to the fixture + to ensure that no other values are yielded and that the loop is closed. + + :param request: The fixture request. + :param func: The function to wrap. + + :returns: The wrapped function. + """ + + @functools.wraps(func) + def _wrapper(*args, **kwargs): + try: + loop, created = asyncio.get_running_loop(), False + except RuntimeError: + loop, created = asyncio.get_event_loop_policy().new_event_loop(), True + + async_obj = func(*args, **kwargs) + + def _finalizer() -> None: + """Ensure no more values are yielded and close the loop.""" + try: + loop.run_until_complete(async_obj.__anext__()) + except StopAsyncIteration: + pass + else: + raise ValueError("Async generator must only yield once.") + + if created: + loop.close() + + value = loop.run_until_complete(async_obj.__anext__()) + request.addfinalizer(_finalizer) + + return value + + return _wrapper + + +def _wrap_coroutine(func: Callable) -> Callable: + """Wrapper for a coroutine function. + + :param func: The function to wrap. + + :returns: The wrapped function. + """ + + @functools.wraps(func) + def _wrapper(*args, **kwargs): + try: + loop, created = asyncio.get_running_loop(), False + except RuntimeError: + loop, created = asyncio.get_event_loop_policy().new_event_loop(), True + + try: + async_obj = func(*args, **kwargs) + return loop.run_until_complete(async_obj) + finally: + if created: + loop.close() + + return _wrapper + + def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequest) -> None: """Execute the scenario. diff --git a/src/pytest_bdd/steps.py b/src/pytest_bdd/steps.py index 5ed3529d..593f1e88 100644 --- a/src/pytest_bdd/steps.py +++ b/src/pytest_bdd/steps.py @@ -66,6 +66,7 @@ class StepFunctionContext: parser: StepParser converters: dict[str, Callable[..., Any]] = field(default_factory=dict) target_fixture: str | None = None + is_async: bool = False def get_step_fixture_name(step: Step) -> str: @@ -78,6 +79,7 @@ def given( converters: dict[str, Callable] | None = None, target_fixture: str | None = None, stacklevel: int = 1, + is_async: bool = False, ) -> Callable: """Given step decorator. @@ -86,10 +88,32 @@ def given( {: }. :param target_fixture: Target fixture name to replace by steps definition function. :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + :param is_async: True if the step is asynchronous. (Default: False) :return: Decorator function for the step. """ - return step(name, GIVEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel) + return step( + name, GIVEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=is_async + ) + + +def async_given( + name: str | StepParser, + converters: dict[str, Callable] | None = None, + target_fixture: str | None = None, + stacklevel: int = 1, +) -> Callable: + """Async Given step decorator. + + :param name: Step name or a parser object. + :param converters: Optional `dict` of the argument or parameter converters in form + {: }. + :param target_fixture: Target fixture name to replace by steps definition function. + :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + + :return: Decorator function for the step. + """ + return given(name, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=True) def when( @@ -97,6 +121,29 @@ def when( converters: dict[str, Callable] | None = None, target_fixture: str | None = None, stacklevel: int = 1, + is_async: bool = False, +) -> Callable: + """When step decorator. + + :param name: Step name or a parser object. + :param converters: Optional `dict` of the argument or parameter converters in form + {: }. + :param target_fixture: Target fixture name to replace by steps definition function. + :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + :param is_async: True if the step is asynchronous. (Default: False) + + :return: Decorator function for the step. + """ + return step( + name, WHEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=is_async + ) + + +def async_when( + name: str | StepParser, + converters: dict[str, Callable] | None = None, + target_fixture: str | None = None, + stacklevel: int = 1, ) -> Callable: """When step decorator. @@ -108,7 +155,7 @@ def when( :return: Decorator function for the step. """ - return step(name, WHEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel) + return when(name, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=True) def then( @@ -116,6 +163,7 @@ def then( converters: dict[str, Callable] | None = None, target_fixture: str | None = None, stacklevel: int = 1, + is_async: bool = False, ) -> Callable: """Then step decorator. @@ -124,10 +172,32 @@ def then( {: }. :param target_fixture: Target fixture name to replace by steps definition function. :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + :param is_async: True if the step is asynchronous. (Default: False) :return: Decorator function for the step. """ - return step(name, THEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel) + return step( + name, THEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=is_async + ) + + +def async_then( + name: str | StepParser, + converters: dict[str, Callable] | None = None, + target_fixture: str | None = None, + stacklevel: int = 1, +) -> Callable: + """Then step decorator. + + :param name: Step name or a parser object. + :param converters: Optional `dict` of the argument or parameter converters in form + {: }. + :param target_fixture: Target fixture name to replace by steps definition function. + :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + + :return: Decorator function for the step. + """ + return step(name, THEN, converters=converters, target_fixture=target_fixture, stacklevel=stacklevel, is_async=True) def step( @@ -136,6 +206,7 @@ def step( converters: dict[str, Callable] | None = None, target_fixture: str | None = None, stacklevel: int = 1, + is_async: bool = False, ) -> Callable[[TCallable], TCallable]: """Generic step decorator. @@ -144,6 +215,7 @@ def step( :param converters: Optional step arguments converters mapping. :param target_fixture: Optional fixture name to replace by step definition. :param stacklevel: Stack level to find the caller frame. This is used when injecting the step definition fixture. + :param is_async: True if the step is asynchronous. (Default: False) :return: Decorator function for the step. @@ -165,6 +237,7 @@ def decorator(func: TCallable) -> TCallable: parser=parser, converters=converters, target_fixture=target_fixture, + is_async=is_async, ) def step_function_marker() -> StepFunctionContext: @@ -177,6 +250,7 @@ def step_function_marker() -> StepFunctionContext: 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 return decorator diff --git a/tests/feature/test_async_steps.py b/tests/feature/test_async_steps.py new file mode 100644 index 00000000..01647fb6 --- /dev/null +++ b/tests/feature/test_async_steps.py @@ -0,0 +1,595 @@ +import textwrap + + +def test_steps(pytester): + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps are executed one by one + Steps are executed one by one. Given and When sections + are not mandatory in some cases. + + Scenario: Executed step by step + Given I have a foo fixture with value "foo" + And there is a list + When I append 1 to the list + And I append 2 to the list + And I append 3 to the list + Then foo should have value "foo" + But the list should be [1, 2, 3] + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario + from pytest_bdd.asyncio import async_given, async_when, async_then + + @scenario("steps.feature", "Executed step by step") + def test_steps(): + pass + + @async_given('I have a foo fixture with value "foo"', target_fixture="foo") + async def _(): + return "foo" + + + @async_given("there is a list", target_fixture="results") + async def _(): + yield [] + + + @async_when("I append 1 to the list") + async def _(results): + results.append(1) + + + @async_when("I append 2 to the list") + async def _(results): + results.append(2) + + + @async_when("I append 3 to the list") + async def _(results): + results.append(3) + + + @async_then('foo should have value "foo"') + async def _(foo): + assert foo == "foo" + + + @async_then("the list should be [1, 2, 3]") + async def _(results): + assert results == [1, 2, 3] + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_step_function_can_be_decorated_multiple_times(pytester): + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps decoration + + Scenario: Step function can be decorated multiple times + Given there is a foo with value 42 + And there is a second foo with value 43 + When I do nothing + And I do nothing again + Then I make no mistakes + And I make no mistakes again + + """ + ), + ) + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario, parsers + from pytest_bdd.asyncio import async_given, async_when, async_then + + @scenario("steps.feature", "Step function can be decorated multiple times") + def test_steps(): + pass + + + @async_given(parsers.parse("there is a foo with value {value}"), target_fixture="foo") + @async_given(parsers.parse("there is a second foo with value {value}"), target_fixture="second_foo") + async def _(value): + return value + + + @async_when("I do nothing") + @async_when("I do nothing again") + async def _(): + pass + + + @async_then("I make no mistakes") + @async_then("I make no mistakes again") + async def _(): + assert True + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_all_steps_can_provide_fixtures(pytester): + """Test that given/when/then can all provide fixtures.""" + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Step fixture + Scenario: Given steps can provide fixture + Given Foo is "bar" + Then foo should be "bar" + Scenario: When steps can provide fixture + When Foo is "baz" + Then foo should be "baz" + Scenario: Then steps can provide fixture + Then foo is "qux" + And foo should be "qux" + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import parsers, scenarios + from pytest_bdd.asyncio import async_given, async_when, async_then + + scenarios("steps.feature") + + @async_given(parsers.parse('Foo is "{value}"'), target_fixture="foo") + async def _(value): + return value + + + @async_when(parsers.parse('Foo is "{value}"'), target_fixture="foo") + async def _(value): + return value + + + @async_then(parsers.parse('Foo is "{value}"'), target_fixture="foo") + async def _(value): + return value + + + @async_then(parsers.parse('foo should be "{value}"')) + async def _(foo, value): + assert foo == value + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=3, failed=0) + + +def test_when_first(pytester): + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps are executed one by one + Steps are executed one by one. Given and When sections + are not mandatory in some cases. + + Scenario: When step can be the first + When I do nothing + Then I make no mistakes + """ + ), + ) + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import when, then, scenario + from pytest_bdd.asyncio import async_when, async_then + + @scenario("steps.feature", "When step can be the first") + def test_steps(): + pass + + @async_when("I do nothing") + async def _(): + pass + + + @async_then("I make no mistakes") + async def _(): + assert True + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_then_after_given(pytester): + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps are executed one by one + Steps are executed one by one. Given and When sections + are not mandatory in some cases. + + Scenario: Then step can follow Given step + Given I have a foo fixture with value "foo" + Then foo should have value "foo" + + """ + ), + ) + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario + from pytest_bdd.asyncio import async_given, async_then + + @scenario("steps.feature", "Then step can follow Given step") + def test_steps(): + pass + + @async_given('I have a foo fixture with value "foo"', target_fixture="foo") + async def _(): + return "foo" + + @async_then('foo should have value "foo"') + async def _(foo): + assert foo == "foo" + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_conftest(pytester): + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps are executed one by one + Steps are executed one by one. Given and When sections + are not mandatory in some cases. + + Scenario: All steps are declared in the conftest + Given I have a bar + Then bar should have value "bar" + + """ + ), + ) + pytester.makeconftest( + textwrap.dedent( + """\ + from pytest_bdd.asyncio import async_given, async_then + + + @async_given("I have a bar", target_fixture="bar") + async def _(): + return "bar" + + + @async_then('bar should have value "bar"') + async def _(bar): + assert bar == "bar" + + """ + ) + ) + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import scenario + + @scenario("steps.feature", "All steps are declared in the conftest") + def test_steps(): + pass + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_multiple_given(pytester): + """Using the same given fixture raises an error.""" + pytester.makefile( + ".feature", + steps=textwrap.dedent( + """\ + Feature: Steps are executed one by one + Scenario: Using the same given twice + Given foo is "foo" + And foo is "bar" + Then foo should be "bar" + + """ + ), + ) + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import parsers, scenario + from pytest_bdd.asyncio import async_given, async_then + + + @async_given(parsers.parse("foo is {value}"), target_fixture="foo") + async def _(value): + return value + + + @async_then(parsers.parse("foo should be {value}")) + async def _(foo, value): + assert foo == value + + + @scenario("steps.feature", "Using the same given twice") + def test_given_twice(): + pass + + """ + ) + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, failed=0) + + +def test_step_hooks(pytester): + """When step fails.""" + pytester.makefile( + ".feature", + test=""" + Scenario: When step has hook on failure + Given I have a bar + When it fails + + Scenario: When step's dependency a has failure + Given I have a bar + When it's dependency fails + + Scenario: When step is not found + Given not found + + Scenario: When step validation error happens + Given foo + And foo + """, + ) + pytester.makepyfile( + """ + import pytest + from pytest_bdd import scenario + from pytest_bdd.asyncio import async_given, async_when + + @async_given('I have a bar') + async def _(): + return 'bar' + + @async_when('it fails') + async def _(): + raise Exception('when fails') + + @async_given('I have a bar') + async def _(): + return 'bar' + + @pytest.fixture + def dependency(): + raise Exception('dependency fails') + + @async_when("it's dependency fails") + async def _(dependency): + pass + + @scenario('test.feature', "When step's dependency a has failure") + def test_when_dependency_fails(): + pass + + @scenario('test.feature', 'When step has hook on failure') + def test_when_fails(): + pass + + @scenario('test.feature', 'When step is not found') + def test_when_not_found(): + pass + + @async_when('foo') + async def _(): + return 'foo' + + @scenario('test.feature', 'When step validation error happens') + def test_when_step_validation_error(): + pass + """ + ) + reprec = pytester.inline_run("-k test_when_fails") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_scenario") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_after_step") + assert calls[0].request + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_not_found") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_step_func_lookup_error") + assert calls[0].request + + reprec = pytester.inline_run("-k test_when_step_validation_error") + reprec.assertoutcome(failed=1) + + reprec = pytester.inline_run("-k test_when_dependency_fails", "-vv") + reprec.assertoutcome(failed=1) + + calls = reprec.getcalls("pytest_bdd_before_step") + assert len(calls) == 2 + + calls = reprec.getcalls("pytest_bdd_before_step_call") + assert len(calls) == 1 + + calls = reprec.getcalls("pytest_bdd_step_error") + assert calls[0].request + + +def test_step_trace(pytester): + """Test step trace.""" + pytester.makeini( + """ + [pytest] + console_output_style=classic + """ + ) + + pytester.makefile( + ".feature", + test=""" + Scenario: When step has failure + Given I have a bar + When it fails + + Scenario: When step is not found + Given not found + + Scenario: When step validation error happens + Given foo + And foo + """, + ) + pytester.makepyfile( + """ + import pytest + from pytest_bdd import scenario + from pytest_bdd.asyncio import async_given, async_when + + @async_given('I have a bar') + async def _(): + return 'bar' + + @async_when('it fails') + async def _(): + raise Exception('when fails') + + @scenario('test.feature', 'When step has failure') + def test_when_fails_inline(): + pass + + @scenario('test.feature', 'When step has failure') + def test_when_fails_decorated(): + pass + + @scenario('test.feature', 'When step is not found') + def test_when_not_found(): + pass + + @async_when('foo') + async def _(): + return 'foo' + + @scenario('test.feature', 'When step validation error happens') + def test_when_step_validation_error(): + pass + """ + ) + result = pytester.runpytest("-k test_when_fails_inline", "-vv") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*test_when_fails_inline*FAILED"]) + assert "INTERNALERROR" not in result.stdout.str() + + result = pytester.runpytest("-k test_when_fails_decorated", "-vv") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*test_when_fails_decorated*FAILED"]) + assert "INTERNALERROR" not in result.stdout.str() + + result = pytester.runpytest("-k test_when_not_found", "-vv") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*test_when_not_found*FAILED"]) + assert "INTERNALERROR" not in result.stdout.str() + + result = pytester.runpytest("-k test_when_step_validation_error", "-vv") + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*test_when_step_validation_error*FAILED"]) + assert "INTERNALERROR" not in result.stdout.str() + + +def test_steps_with_yield(pytester): + """Test that steps definition containing a yield statement work the same way as + pytest fixture do, that is the code after the yield is executed during teardown.""" + + pytester.makefile( + ".feature", + a="""\ +Feature: A feature + + Scenario: A scenario + When I setup stuff + Then stuff should be 42 +""", + ) + pytester.makepyfile( + textwrap.dedent( + """\ + import pytest + from pytest_bdd import scenarios + from pytest_bdd.asyncio import async_when, async_then + + scenarios("a.feature") + + @async_when("I setup stuff", target_fixture="stuff") + async def _(): + print("Setting up...") + yield 42 + print("Tearing down...") + + + @async_then("stuff should be 42") + async def _(stuff): + assert stuff == 42 + print("Asserted stuff is 42") + + """ + ) + ) + result = pytester.runpytest("-s") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines( + [ + "*Setting up...*", + "*Asserted stuff is 42*", + "*Tearing down...*", + ] + ) diff --git a/tests/steps/test_common.py b/tests/steps/test_common.py index 535f785a..7179e17e 100644 --- a/tests/steps/test_common.py +++ b/tests/steps/test_common.py @@ -5,11 +5,22 @@ import pytest from pytest_bdd import given, parsers, then, when +from pytest_bdd.asyncio import async_given, async_then, async_when from pytest_bdd.utils import collect_dumped_objects -@pytest.mark.parametrize("step_fn, step_type", [(given, "given"), (when, "when"), (then, "then")]) -def test_given_when_then_delegate_to_step(step_fn: Callable[..., Any], step_type: str) -> None: +@pytest.mark.parametrize( + "step_fn, step_type, is_async", + [ + (given, "given", False), + (when, "when", False), + (then, "then", False), + (async_given, "given", True), + (async_when, "when", True), + (async_then, "then", True), + ], +) +def test_given_when_then_delegate_to_step(step_fn: Callable[..., Any], step_type: str, is_async: bool) -> None: """Test that @given, @when, @then just delegate the work to @step(...). This way we don't have to repeat integration tests for each step decorator. """ @@ -18,7 +29,9 @@ def test_given_when_then_delegate_to_step(step_fn: Callable[..., Any], step_type with mock.patch("pytest_bdd.steps.step", autospec=True) as step_mock: step_fn("foo") - step_mock.assert_called_once_with("foo", type_=step_type, converters=None, target_fixture=None, stacklevel=1) + step_mock.assert_called_once_with( + "foo", type_=step_type, converters=None, target_fixture=None, stacklevel=1, is_async=is_async + ) # Advanced usage: step parser, converters, target_fixture, ... with mock.patch("pytest_bdd.steps.step", autospec=True) as step_mock: @@ -26,7 +39,7 @@ def test_given_when_then_delegate_to_step(step_fn: Callable[..., Any], step_type step_fn(parser, converters={"n": int}, target_fixture="foo_n", stacklevel=3) step_mock.assert_called_once_with( - name=parser, type_=step_type, converters={"n": int}, target_fixture="foo_n", stacklevel=3 + name=parser, type_=step_type, converters={"n": int}, target_fixture="foo_n", stacklevel=3, is_async=is_async )