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

Add support for async steps & scenarios #349

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
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
26 changes: 26 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,32 @@ test_publish_article.py:
pass


Using Asyncio
-------------

Async scenario functions have to be marked with `@pytest.mark.asyncio`.

.. code-block:: python

@pytest.mark.asyncio
@scenario('test.feature', 'Launching scenario function')
async def test_launching_scenario_function():
pass

@given("i have async step")
async def async_given():
pass


@when("i do async step")
async def async_when():
pass


@then("i should have async step")
async def async_then():
pass

Hooks
-----

Expand Down
8 changes: 5 additions & 3 deletions pytest_bdd/scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
from .feature import Feature, force_unicode, get_features
from .steps import get_caller_module, get_step_fixture_name, inject_fixture
from .types import GIVEN
from .utils import CONFIG_STACK, get_args

from .utils import CONFIG_STACK, get_args, run_coroutines

PYTHON_REPLACE_REGEX = re.compile(r"\W")
ALPHA_REGEX = re.compile(r"^\d+_*")
Expand Down Expand Up @@ -111,8 +110,11 @@ def _execute_step_function(request, scenario, step, step_func):
kw["step_func_args"] = kwargs

request.config.hook.pytest_bdd_before_step_call(**kw)

# Execute the step.
step_func(**kwargs)
result_or_coro = step_func(**kwargs)
run_coroutines(result_or_coro, request=request)

request.config.hook.pytest_bdd_after_step(**kw)
except Exception as exception:
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
Expand Down
42 changes: 41 additions & 1 deletion pytest_bdd/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Various utility functions."""

import inspect

from _pytest.fixtures import FixtureLookupError

CONFIG_STACK = []


Expand Down Expand Up @@ -31,3 +32,42 @@ def get_parametrize_markers_args(node):
This function uses that API if it is available otherwise it uses MarkInfo objects.
"""
return tuple(arg for mark in node.iter_markers("parametrize") for arg in mark.args)


def run_coroutines(*results_or_coroutines, request):
"""
Takes provided coroutine(s) or function(s) result(s) (that can be any type) and for every one of them:
* if it is coroutine - runs it using event_loop fixture and adds its result to the batch,
* if it isn't coroutine - just adds it to the batch.
Then returns batch of results (or single result).

Example usage:
>>> def regular_fn(): return 24
>>> async def async_fn(): return 42
>>>
>>> assert run_coroutines(regular_fn(), request=request) == 24
>>> assert run_coroutines(async_fn(), request=request) == 42
>>> assert run_coroutines(regular_fn(), async_fn(), request=request) == (24, 42)

:param results_or_coroutines: coroutine(s) to run or function results to let-through
:param request: request fixture
:return: single result (if there was single coroutine/result provided as input) or multiple results (otherwise)
"""

def run_with_event_loop_fixture(coro):
try:
event_loop = request.getfixturevalue("event_loop")
except FixtureLookupError:
raise ValueError("Install pytest-asyncio plugin to run asynchronous steps.")

return event_loop.run_until_complete(coro)

results = [
run_with_event_loop_fixture(result_or_coro) if inspect.iscoroutine(result_or_coro) else result_or_coro
for result_or_coro in results_or_coroutines
]

if len(results) == 1:
return results[0]
else:
return tuple(results)
5 changes: 5 additions & 0 deletions requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
mock
requests
flask_api
aiohttp
pytest-asyncio
async_generator
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"pytest11": ["pytest-bdd = pytest_bdd.plugin"],
"console_scripts": ["pytest-bdd = pytest_bdd.scripts:main"],
},
tests_require=["tox"],
tests_require=["tox", "flask", "requests", "flask_api", "aiohttp", "pytest-asyncio", "async_generator"],
packages=["pytest_bdd"],
include_package_data=True,
)
Empty file added tests/asyncio/__init__.py
Empty file.
1 change: 1 addition & 0 deletions tests/asyncio/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from tests.asyncio.dummy_app import *
144 changes: 144 additions & 0 deletions tests/asyncio/dummy_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import asyncio
import contextlib
import time
from contextlib import contextmanager
from datetime import datetime, timedelta
from multiprocessing.context import Process

import aiohttp
import pytest
import requests
from async_generator import yield_, async_generator
from flask import Flask, jsonify
from flask import request
from flask_api.status import HTTP_404_NOT_FOUND, HTTP_200_OK


@contextmanager
def setup_and_teardown_flask_app(app: Flask, host: str, port: int):
"""
Manages setup of provided flask app on given `host` and `port` and its teardown.

As for setup process following things are done:
* `/health` endpoint is added to provided flask app,
* app is launched in separate process,
* function waits for flask app to fully launch - to do this it repetitively checks `/health` endpoint if it will
return status code 200.

Example use of this function in fixture:

>>> with setup_and_teardown_flask_app(Flask(__name__), "localhost", 10000):
>>> yield

:param app: app to launch
:param host: host on which to launch app
:param port: port on which to launch app
"""

def wait_for_flask_app_to_be_accessible():
timeout = 1
end_time = datetime.now() + timedelta(seconds=timeout)
response = requests.Response()
response.status_code = HTTP_404_NOT_FOUND

while response.status_code != HTTP_200_OK and datetime.now() < end_time:
with contextlib.suppress(requests.exceptions.ConnectionError):
response = requests.request("POST", "http://{}:{}/health".format(host, port))
time.sleep(0.01)

fail_message = "Timeout expired: failed to start mock REST API in {} seconds".format(timeout)
assert response.status_code == HTTP_200_OK, fail_message

app.route("/health", methods=["POST"])(lambda: "OK")

process = Process(target=app.run, args=(host, port))
process.start()

wait_for_flask_app_to_be_accessible()
yield

process.terminate()
process.join()


def create_server():
app = Flask(__name__)
app.pre_computation_value = 0
app.post_computation_value = 0

@app.route("/pre-computation-value", methods=["PUT"])
def set_pre_computation_value():
app.pre_computation_value = request.json["value"]
return ""

@app.route("/pre-computation-value", methods=["GET"])
def get_pre_computation_value():
return jsonify(app.pre_computation_value)

@app.route("/post-computation-value", methods=["PUT"])
def set_post_computation_value():
app.post_computation_value = request.json["value"]
return ""

@app.route("/post-computation-value", methods=["GET"])
def get_post_computation_value():
return jsonify(app.post_computation_value)

return app


class DummyApp:
"""
This has to simulate real application that gets input from server, processes it and posts it.
"""

def __init__(self, host, port, tick_rate_s):
self.host = host
self.port = port
self.tick_rate_s = tick_rate_s
self.stored_value = 0

async def run(self):
await asyncio.gather(self.run_getter(), self.run_poster())

async def run_getter(self):
async with aiohttp.ClientSession() as session:
while True:
response = await session.get("http://{}:{}/pre-computation-value".format(self.host, self.port))
self.stored_value = int(await response.text())
await asyncio.sleep(self.tick_rate_s)

async def run_poster(self):
async with aiohttp.ClientSession() as session:
while True:
await session.put(
"http://{}:{}/post-computation-value".format(self.host, self.port),
json={"value": self.stored_value + 1},
)
await asyncio.sleep(self.tick_rate_s)


@pytest.fixture
def dummy_server_host():
return "localhost"


@pytest.fixture
def launch_dummy_server(dummy_server_host, unused_tcp_port):
with setup_and_teardown_flask_app(create_server(), dummy_server_host, unused_tcp_port):
yield


@pytest.fixture
def app_tick_interval():
return 0.01


@pytest.fixture
@async_generator
async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, unused_tcp_port, app_tick_interval):
app = DummyApp(dummy_server_host, unused_tcp_port, app_tick_interval)
task = event_loop.create_task(app.run())
await yield_(None)
task.cancel()
await asyncio.sleep(0)
9 changes: 9 additions & 0 deletions tests/asyncio/test_async_given_returns_value.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Feature: Async given is a fixture and its value is properly returned

Scenario: Async given shadows fixture
Given i have given that shadows fixture with value of 42
Then shadowed fixture value should be equal to 42

Scenario: Async given is a fixture
Given i have given that is a fixture with value of 42
Then value of given as a fixture should be equal to 42
30 changes: 30 additions & 0 deletions tests/asyncio/test_async_given_returns_value.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import pytest

from pytest_bdd import given, parsers, then, scenarios

scenarios("test_async_given_returns_value.feature")


@pytest.fixture
def my_value():
return 0


@given(parsers.parse("i have given that shadows fixture with value of {value:d}"), target_fixture="my_value")
async def i_have_given_that_shadows_fixture_with_value_of(value):
return value


@given(parsers.parse("i have given that is a fixture with value of {value:d}"))
async def i_have_given_that_is_a_fixture_with_value_of(value):
return value


@then(parsers.parse("shadowed fixture value should be equal to {value:d}"))
async def my_fixture_value_should_be_equal_to(value, my_value):
assert value == my_value


@then(parsers.parse("value of given as a fixture should be equal to {value:d}"))
async def value_of_given_as_a_fixture_should_be_equal_to(value, i_have_given_that_is_a_fixture_with_value_of):
assert value == i_have_given_that_is_a_fixture_with_value_of
61 changes: 61 additions & 0 deletions tests/asyncio/test_async_scenario_function.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import textwrap

import pytest


@pytest.fixture
def feature_file(testdir):
testdir.makefile(
".feature",
test=textwrap.dedent(
"""
Feature: Async scenario function is being launched

Scenario: Launching scenario function
"""
),
)


def test_scenario_function_marked_with_async_passes(feature_file, testdir):
testdir.makepyfile(
textwrap.dedent(
"""
import pytest
from pytest_bdd import scenario

@pytest.mark.asyncio
@scenario('test.feature', 'Launching scenario function')
async def test_launching_scenario_function():
pass
"""
)
)

result = testdir.runpytest()
result.assert_outcomes(passed=1)


PYTEST_VERSION = tuple([int(i) for i in pytest.__version__.split(".")])


@pytest.mark.skipif(
PYTEST_VERSION < (5, 1, 0),
reason="Async functions not marked as @pytest.mark.asyncio are silently passing on pytest < 5.1.0",
)
def test_scenario_function_not_marked_with_async_fails(feature_file, testdir):
testdir.makepyfile(
textwrap.dedent(
"""
import pytest
from pytest_bdd import scenario

@scenario('test.feature', 'Launching scenario function')
async def test_launching_scenario_function():
pass
"""
)
)

result = testdir.runpytest()
result.assert_outcomes(failed=1)
16 changes: 16 additions & 0 deletions tests/asyncio/test_async_steps.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Feature: Async steps

Scenario: Async steps are actually executed
Given i have async step
When i do async step
Then i should have async step

Scenario: Async steps are executed along with regular steps
Given i have async step
And i have regular step

When i do async step
And i do regular step

Then i should have async step
And i should have regular step
Loading