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 hooks #350

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7a51bb0
add possibility to run async steps
amadeuszhercog-silvair Dec 31, 2019
beb111e
move fixtures to separate file
Xaaq Jan 25, 2020
0741b16
rename feature for launching app in task
Xaaq Jan 29, 2020
ab6d3b6
use unused_tcp_port fixture from pytest-asyncio
Xaaq Jan 29, 2020
31d9a48
create scenarios for checking if async steps work
Xaaq Jan 29, 2020
da10429
add TODO
Xaaq Jan 29, 2020
4262c40
add pooling instead of wait step
Xaaq Feb 2, 2020
5a2f218
add fixture with app tick rate
Xaaq Feb 2, 2020
9ef9ec5
move sleeps in dummy app below rest of the body
Xaaq Feb 2, 2020
9588bf4
add "test_" to feature files names
Xaaq Feb 2, 2020
47e3947
cleanup starting flask test server
Xaaq Feb 2, 2020
1403c46
rename and decrease app_tick_rate to app_tick_interval
Xaaq Feb 2, 2020
50ad173
add tests for given to be a fixture
Xaaq Feb 2, 2020
60085b2
extract code for running coroutines to function
Xaaq Feb 2, 2020
5362eef
cleanup
Xaaq Feb 3, 2020
e4020ae
add tests for checking if async scenario is launched
Xaaq Feb 3, 2020
4e9bef5
add test requirements
Xaaq Mar 9, 2020
6fc9340
delete test
Xaaq Mar 9, 2020
aafa8d4
add support for python 3.6 and 3.5
Xaaq Mar 9, 2020
cd7562e
add test back and skip it if pytest < 5.1.0
Xaaq Mar 9, 2020
1ab24b8
add 'using asyncio' section to docs
Xaaq Mar 11, 2020
4eaa6a3
add async_generator to setup.py
amadeuszhercog-silvair Mar 12, 2020
37d9168
make async hooks be executed
Xaaq Feb 2, 2020
2a9e575
add tests for async hooks
Xaaq Feb 3, 2020
c95304c
remove unused hooks from conftest
Xaaq Mar 9, 2020
2054d23
add 'async hooks' section to readme
Xaaq Mar 11, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.rst
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 Expand Up @@ -1112,6 +1138,16 @@ which might be helpful building useful reporting, visualization, etc on top of i
* pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception) - Called when step lookup failed


Async hooks
-----------

If you want any of above hooks be asynchronous just define it as `async def` instead of `def` like this:

.. code-block:: python

async def pytest_bdd_before_scenario(request, feature, scenario):
pass

Browser testing
---------------

Expand Down
32 changes: 21 additions & 11 deletions pytest_bdd/scenario.py
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 @@ -102,20 +101,23 @@ def _execute_step_function(request, scenario, step, step_func):
"""
kw = dict(request=request, feature=scenario.feature, scenario=scenario, step=step, step_func=step_func)

request.config.hook.pytest_bdd_before_step(**kw)
run_coroutines(*request.config.hook.pytest_bdd_before_step(**kw), request=request)

kw["step_func_args"] = {}
try:
# Get the step argument values.
kwargs = dict((arg, request.getfixturevalue(arg)) for arg in get_args(step_func))
kw["step_func_args"] = kwargs

request.config.hook.pytest_bdd_before_step_call(**kw)
run_coroutines(*request.config.hook.pytest_bdd_before_step_call(**kw), request=request)

# Execute the step.
step_func(**kwargs)
request.config.hook.pytest_bdd_after_step(**kw)
result_or_coro = step_func(**kwargs)
run_coroutines(result_or_coro, request=request)

run_coroutines(*request.config.hook.pytest_bdd_after_step(**kw), request=request)
except Exception as exception:
request.config.hook.pytest_bdd_step_error(exception=exception, **kw)
run_coroutines(*request.config.hook.pytest_bdd_step_error(exception=exception, **kw), request=request)
raise


Expand All @@ -127,7 +129,10 @@ def _execute_scenario(feature, scenario, request, encoding):
:param request: request.
:param encoding: Encoding.
"""
request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario)
run_coroutines(
*request.config.hook.pytest_bdd_before_scenario(request=request, feature=feature, scenario=scenario),
request=request
)

try:
givens = set()
Expand All @@ -136,9 +141,10 @@ def _execute_scenario(feature, scenario, request, encoding):
try:
step_func = _find_step_function(request, step, scenario, encoding=encoding)
except exceptions.StepDefinitionNotFoundError as exception:
request.config.hook.pytest_bdd_step_func_lookup_error(
results_or_coros = request.config.hook.pytest_bdd_step_func_lookup_error(
request=request, feature=feature, scenario=scenario, step=step, exception=exception
)
run_coroutines(*results_or_coros, request=request)
raise

try:
Expand All @@ -152,7 +158,7 @@ def _execute_scenario(feature, scenario, request, encoding):
)
givens.add(step_func.fixture)
except exceptions.ScenarioValidationError as exception:
request.config.hook.pytest_bdd_step_validation_error(
results_or_coros = request.config.hook.pytest_bdd_step_validation_error(
request=request,
feature=feature,
scenario=scenario,
Expand All @@ -161,11 +167,15 @@ def _execute_scenario(feature, scenario, request, encoding):
exception=exception,
step_func_args=dict((arg, request.getfixturevalue(arg)) for arg in get_args(step_func)),
)
run_coroutines(*results_or_coros, request=request)
raise

_execute_step_function(request, scenario, step, step_func)
finally:
request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario)
run_coroutines(
*request.config.hook.pytest_bdd_after_scenario(request=request, feature=feature, scenario=scenario),
request=request
)


FakeRequest = collections.namedtuple("FakeRequest", ["module"])
Expand Down
42 changes: 41 additions & 1 deletion pytest_bdd/utils.py
@@ -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
@@ -1 +1,6 @@
mock
requests
flask_api
aiohttp
pytest-asyncio
async_generator
2 changes: 1 addition & 1 deletion setup.py
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
@@ -0,0 +1 @@
from tests.asyncio.dummy_app import *
144 changes: 144 additions & 0 deletions tests/asyncio/dummy_app.py
@@ -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
@@ -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
@@ -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