From c292b9a33d2f0cdaca5dbd664673d92c23bf7b0c Mon Sep 17 00:00:00 2001 From: Matt Austin Date: Sat, 15 Apr 2023 14:33:34 -0400 Subject: [PATCH] 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 )