diff --git a/poetry.lock b/poetry.lock index fcb515fd..080590e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -425,6 +425,24 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.21.1" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, + {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + [[package]] name = "pytest-xdist" version = "3.3.1" @@ -553,4 +571,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8" -content-hash = "b2ba37df5fea186a1726b64f9c426661c8b228b3b6f9954eeba805e7653a087c" +content-hash = "a060ad36b10706adf9d533433154ce45a0282675dbb9d9873cf3162a152cb8e1" diff --git a/pyproject.toml b/pyproject.toml index abc3fee1..87fb4166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ types-setuptools = "^65.5.0.2" pytest-xdist = "^3.0.2" coverage = {extras = ["toml"], version = "^6.5.0"} Pygments = "^2.13.0" # for code-block highlighting +pytest-asyncio = "^0.21.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/src/pytest_bdd/scenario.py b/src/pytest_bdd/scenario.py index df7c029c..47b97360 100644 --- a/src/pytest_bdd/scenario.py +++ b/src/pytest_bdd/scenario.py @@ -12,7 +12,9 @@ """ from __future__ import annotations +import asyncio import contextlib +import functools import logging import os import re @@ -120,6 +122,19 @@ def get_step_function(request, step: Step) -> StepFunctionContext | None: return None +def ensure_sync(fn): + """Convert async function to sync function.""" + __tracebackhide__ = True + if not asyncio.iscoroutinefunction(fn): + return fn + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + return asyncio.run(fn(*args, **kwargs)) + + return wrapper + + def _execute_step_function( request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext ) -> None: @@ -156,7 +171,8 @@ 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 = ensure_sync(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 diff --git a/tests/library/test_async.py b/tests/library/test_async.py new file mode 100644 index 00000000..531aecbd --- /dev/null +++ b/tests/library/test_async.py @@ -0,0 +1,69 @@ +import textwrap + + +# TODO: Split this test in one that checks that we work correctly +# with the pytest-asyncio plugin, and another that checks that we correctly +# run async steps. +def test_async_steps(pytester): + """Test parent given is collected. + + Both fixtures come from the parent conftest. + """ + pytester.makefile( + ".feature", + async_feature=textwrap.dedent( + """\ + Feature: A feature + Scenario: A scenario + Given There is an async object + + When I do an async action + + Then the async object value should be "async_object" + And [async] the async object value should be "async_object" + And the another async object value should be "another_async_object" + """ + ), + ) + + pytester.makepyfile( + textwrap.dedent( + """\ + from pytest_bdd import given, parsers, scenarios, then, when + import asyncio + import pytest + + scenarios("async_feature.feature") + + @pytest.fixture + async def another_async_object(): + await asyncio.sleep(0.01) + return "another_async_object" + + @given("There is an async object", target_fixture="async_object") + async def given_async_obj(): + await asyncio.sleep(0.01) + return "async_object" + + @when("I do an async action") + async def when_i_do_async_action(): + await asyncio.sleep(0.01) + + @then(parsers.parse('the async object value should be "{value}"')) + async def the_sync_object_value_should_be(async_object, value): + assert async_object == value + + @then(parsers.parse('[async] the async object value should be "{value}"')) + async def async_the_async_object_value_should_be(async_object, value): + await asyncio.sleep(0.01) + assert async_object == value + + @then(parsers.parse('the another async object value should be "{value}"')) + def the_another_async_object_value_should_be(another_async_object, value): + assert another_async_object == value + + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=1)