From 7a51bb037a2f1f05004386c40abaf7feba9c8bff Mon Sep 17 00:00:00 2001 From: amadeusz Date: Tue, 31 Dec 2019 14:31:17 +0100 Subject: [PATCH 01/26] add possibility to run async steps --- pytest_bdd/scenario.py | 12 +++- setup.py | 2 +- tests/asyncio/__init__.py | 0 tests/asyncio/app.py | 114 ++++++++++++++++++++++++++++++ tests/asyncio/basic_usage.feature | 7 ++ tests/asyncio/test_basic_usage.py | 59 ++++++++++++++++ 6 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/asyncio/__init__.py create mode 100644 tests/asyncio/app.py create mode 100644 tests/asyncio/basic_usage.feature create mode 100644 tests/asyncio/test_basic_usage.py diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index ba12bcb8..2d8f8c6b 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -10,12 +10,14 @@ scenario_name="Publishing the article", ) """ +import asyncio import collections import inspect import os import re import pytest +from _pytest.fixtures import FixtureLookupError try: from _pytest import fixtures as pytest_fixtures @@ -111,8 +113,16 @@ 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) + if inspect.iscoroutine(result_or_coro): + try: + event_loop = request.getfixturevalue("event_loop") + except FixtureLookupError: + raise ValueError("Install pytest-asyncio plugin to run asynchronous steps.") + event_loop.run_until_complete(result_or_coro) + request.config.hook.pytest_bdd_after_step(**kw) except Exception as exception: request.config.hook.pytest_bdd_step_error(exception=exception, **kw) diff --git a/setup.py b/setup.py index bdc9ca07..05a51122 100755 --- a/setup.py +++ b/setup.py @@ -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"], packages=["pytest_bdd"], include_package_data=True, ) diff --git a/tests/asyncio/__init__.py b/tests/asyncio/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/asyncio/app.py b/tests/asyncio/app.py new file mode 100644 index 00000000..2761eebc --- /dev/null +++ b/tests/asyncio/app.py @@ -0,0 +1,114 @@ +import asyncio +import contextlib +import time +from contextlib import contextmanager +from multiprocessing.context import Process + +import aiohttp +import requests +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 + start_time = time.time() + response = requests.Response() + response.status_code = HTTP_404_NOT_FOUND + + while response.status_code != HTTP_200_OK and time.time() - start_time <= timeout: + with contextlib.suppress(requests.exceptions.ConnectionError): + response = requests.request("POST", f"http://{host}:{port}/health") + time.sleep(0.01) + + fail_message = f"Timeout expired: failed to start mock REST API in {timeout} seconds" + 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): + self.host = host + self.port = port + 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: + await asyncio.sleep(0.1) + response = await session.get("http://{}:{}/pre-computation-value".format(self.host, self.port)) + self.stored_value = int(await response.text()) + + async def run_poster(self): + async with aiohttp.ClientSession() as session: + while True: + await asyncio.sleep(0.1) + await session.put( + "http://{}:{}/post-computation-value".format(self.host, self.port), + json={"value": self.stored_value + 1}, + ) diff --git a/tests/asyncio/basic_usage.feature b/tests/asyncio/basic_usage.feature new file mode 100644 index 00000000..5b4e5678 --- /dev/null +++ b/tests/asyncio/basic_usage.feature @@ -0,0 +1,7 @@ +Feature: Basic usage of asynchronous steps + + Scenario: Launching app in task + Given i have launched app + When i post input variable to have value of 3 + And i wait 1 second(s) + Then output value should be equal to 4 diff --git a/tests/asyncio/test_basic_usage.py b/tests/asyncio/test_basic_usage.py new file mode 100644 index 00000000..0a77a91b --- /dev/null +++ b/tests/asyncio/test_basic_usage.py @@ -0,0 +1,59 @@ +import asyncio + +import aiohttp +import pytest + +from pytest_bdd import given, when, then, scenarios, parsers +from tests.asyncio.app import create_server, setup_and_teardown_flask_app, DummyApp + +scenarios("basic_usage.feature") + + +@pytest.fixture +def dummy_server_host(): + return "localhost" + + +@pytest.fixture +def dummy_server_port(): + return 10000 + + +@pytest.fixture +def launch_dummy_server(dummy_server_host, dummy_server_port): + with setup_and_teardown_flask_app(create_server(), dummy_server_host, dummy_server_port): + yield + + +@pytest.fixture +async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, dummy_server_port): + app = DummyApp(dummy_server_host, dummy_server_port) + task = event_loop.create_task(app.run()) + yield + task.cancel() + await asyncio.sleep(0) + + +@given("i have launched app") +async def i_have_launched_app(launch_dummy_app): + pass + + +@when(parsers.parse("i post input variable to have value of {value:d}")) +async def i_post_input_variable(value, dummy_server_host, dummy_server_port): + async with aiohttp.ClientSession() as session: + endpoint = "http://{}:{}/pre-computation-value".format(dummy_server_host, dummy_server_port) + await session.put(endpoint, json={"value": value}) + + +@when(parsers.parse("i wait {seconds:d} second(s)")) +async def i_wait(seconds): + await asyncio.sleep(seconds) + + +@then(parsers.parse("output value should be equal to {value:d}")) +async def output_value_should_be_equal_to(value, dummy_server_host, dummy_server_port): + async with aiohttp.ClientSession() as session: + response = await session.get("http://{}:{}/post-computation-value".format(dummy_server_host, dummy_server_port)) + output_value = int(await response.text()) + assert output_value == value From beb111edc2c1752b520a980c28dd04d23d7844d8 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sat, 25 Jan 2020 22:33:35 +0100 Subject: [PATCH 02/26] move fixtures to separate file --- tests/asyncio/conftest.py | 1 + tests/asyncio/{app.py => dummy_app.py} | 26 +++++++++++++++++++++++++ tests/asyncio/test_basic_usage.py | 27 -------------------------- 3 files changed, 27 insertions(+), 27 deletions(-) create mode 100644 tests/asyncio/conftest.py rename tests/asyncio/{app.py => dummy_app.py} (86%) diff --git a/tests/asyncio/conftest.py b/tests/asyncio/conftest.py new file mode 100644 index 00000000..f61bab62 --- /dev/null +++ b/tests/asyncio/conftest.py @@ -0,0 +1 @@ +from tests.asyncio.dummy_app import * diff --git a/tests/asyncio/app.py b/tests/asyncio/dummy_app.py similarity index 86% rename from tests/asyncio/app.py rename to tests/asyncio/dummy_app.py index 2761eebc..313f2e37 100644 --- a/tests/asyncio/app.py +++ b/tests/asyncio/dummy_app.py @@ -5,6 +5,7 @@ from multiprocessing.context import Process import aiohttp +import pytest import requests from flask import Flask, jsonify from flask import request @@ -112,3 +113,28 @@ async def run_poster(self): "http://{}:{}/post-computation-value".format(self.host, self.port), json={"value": self.stored_value + 1}, ) + + +@pytest.fixture +def dummy_server_host(): + return "localhost" + + +@pytest.fixture +def dummy_server_port(): + return 10000 + + +@pytest.fixture +def launch_dummy_server(dummy_server_host, dummy_server_port): + with setup_and_teardown_flask_app(create_server(), dummy_server_host, dummy_server_port): + yield + + +@pytest.fixture +async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, dummy_server_port): + app = DummyApp(dummy_server_host, dummy_server_port) + task = event_loop.create_task(app.run()) + yield + task.cancel() + await asyncio.sleep(0) diff --git a/tests/asyncio/test_basic_usage.py b/tests/asyncio/test_basic_usage.py index 0a77a91b..c580afd9 100644 --- a/tests/asyncio/test_basic_usage.py +++ b/tests/asyncio/test_basic_usage.py @@ -1,39 +1,12 @@ import asyncio import aiohttp -import pytest from pytest_bdd import given, when, then, scenarios, parsers -from tests.asyncio.app import create_server, setup_and_teardown_flask_app, DummyApp scenarios("basic_usage.feature") -@pytest.fixture -def dummy_server_host(): - return "localhost" - - -@pytest.fixture -def dummy_server_port(): - return 10000 - - -@pytest.fixture -def launch_dummy_server(dummy_server_host, dummy_server_port): - with setup_and_teardown_flask_app(create_server(), dummy_server_host, dummy_server_port): - yield - - -@pytest.fixture -async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, dummy_server_port): - app = DummyApp(dummy_server_host, dummy_server_port) - task = event_loop.create_task(app.run()) - yield - task.cancel() - await asyncio.sleep(0) - - @given("i have launched app") async def i_have_launched_app(launch_dummy_app): pass From 0741b165781d21950d09f1136290ef77da680a19 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 29 Jan 2020 21:48:34 +0100 Subject: [PATCH 03/26] rename feature for launching app in task --- ...asic_usage.feature => launching_app_in_background.feature} | 4 ++-- ...est_basic_usage.py => test_launching_app_in_background.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename tests/asyncio/{basic_usage.feature => launching_app_in_background.feature} (61%) rename tests/asyncio/{test_basic_usage.py => test_launching_app_in_background.py} (95%) diff --git a/tests/asyncio/basic_usage.feature b/tests/asyncio/launching_app_in_background.feature similarity index 61% rename from tests/asyncio/basic_usage.feature rename to tests/asyncio/launching_app_in_background.feature index 5b4e5678..930e1b87 100644 --- a/tests/asyncio/basic_usage.feature +++ b/tests/asyncio/launching_app_in_background.feature @@ -1,6 +1,6 @@ -Feature: Basic usage of asynchronous steps +Feature: Launching application in async task - Scenario: Launching app in task + Scenario: App is running during whole scenario Given i have launched app When i post input variable to have value of 3 And i wait 1 second(s) diff --git a/tests/asyncio/test_basic_usage.py b/tests/asyncio/test_launching_app_in_background.py similarity index 95% rename from tests/asyncio/test_basic_usage.py rename to tests/asyncio/test_launching_app_in_background.py index c580afd9..94ac4967 100644 --- a/tests/asyncio/test_basic_usage.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -4,7 +4,7 @@ from pytest_bdd import given, when, then, scenarios, parsers -scenarios("basic_usage.feature") +scenarios("launching_app_in_background.feature") @given("i have launched app") From ab6d3b616b01ecf220d9d62262be61ca0f10bdc0 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 29 Jan 2020 21:56:44 +0100 Subject: [PATCH 04/26] use unused_tcp_port fixture from pytest-asyncio --- tests/asyncio/dummy_app.py | 13 ++++--------- tests/asyncio/test_launching_app_in_background.py | 8 ++++---- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index 313f2e37..0ddbd599 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -121,19 +121,14 @@ def dummy_server_host(): @pytest.fixture -def dummy_server_port(): - return 10000 - - -@pytest.fixture -def launch_dummy_server(dummy_server_host, dummy_server_port): - with setup_and_teardown_flask_app(create_server(), dummy_server_host, dummy_server_port): +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 -async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, dummy_server_port): - app = DummyApp(dummy_server_host, dummy_server_port) +async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, unused_tcp_port): + app = DummyApp(dummy_server_host, unused_tcp_port) task = event_loop.create_task(app.run()) yield task.cancel() diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index 94ac4967..b116d170 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -13,9 +13,9 @@ async def i_have_launched_app(launch_dummy_app): @when(parsers.parse("i post input variable to have value of {value:d}")) -async def i_post_input_variable(value, dummy_server_host, dummy_server_port): +async def i_post_input_variable(value, dummy_server_host, unused_tcp_port): async with aiohttp.ClientSession() as session: - endpoint = "http://{}:{}/pre-computation-value".format(dummy_server_host, dummy_server_port) + endpoint = "http://{}:{}/pre-computation-value".format(dummy_server_host, unused_tcp_port) await session.put(endpoint, json={"value": value}) @@ -25,8 +25,8 @@ async def i_wait(seconds): @then(parsers.parse("output value should be equal to {value:d}")) -async def output_value_should_be_equal_to(value, dummy_server_host, dummy_server_port): +async def output_value_should_be_equal_to(value, dummy_server_host, unused_tcp_port): async with aiohttp.ClientSession() as session: - response = await session.get("http://{}:{}/post-computation-value".format(dummy_server_host, dummy_server_port)) + response = await session.get("http://{}:{}/post-computation-value".format(dummy_server_host, unused_tcp_port)) output_value = int(await response.text()) assert output_value == value From 31d9a48291729f910d92dd1d7d7b3f551a596fad Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 29 Jan 2020 21:57:12 +0100 Subject: [PATCH 05/26] create scenarios for checking if async steps work --- tests/asyncio/async_steps.feature | 16 +++++++++++ tests/asyncio/test_async_steps.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 tests/asyncio/async_steps.feature create mode 100644 tests/asyncio/test_async_steps.py diff --git a/tests/asyncio/async_steps.feature b/tests/asyncio/async_steps.feature new file mode 100644 index 00000000..8d170992 --- /dev/null +++ b/tests/asyncio/async_steps.feature @@ -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 diff --git a/tests/asyncio/test_async_steps.py b/tests/asyncio/test_async_steps.py new file mode 100644 index 00000000..d8fdaa57 --- /dev/null +++ b/tests/asyncio/test_async_steps.py @@ -0,0 +1,48 @@ +import pytest + +from pytest_bdd import then, when, given, scenario + + +@pytest.fixture +def test_value(): + return {"value": 0} + + +@scenario("async_steps.feature", "Async steps are actually executed") +def test_async_steps_do_work(test_value): + assert test_value["value"] == 3 + + +@scenario("async_steps.feature", "Async steps are executed along with regular steps") +def test_async_steps_work_with_regular_ones(test_value): + assert test_value["value"] == 6 + + +@given("i have async step") +async def async_step(test_value): + test_value["value"] += 1 + + +@given("i have regular step") +def i_have_regular_step(test_value): + test_value["value"] += 1 + + +@when("i do async step") +async def i_do_async_step(test_value): + test_value["value"] += 1 + + +@when("i do regular step") +def i_do_regular_step(test_value): + test_value["value"] += 1 + + +@then("i should have async step") +async def i_should_have_async_step(test_value): + test_value["value"] += 1 + + +@then("i should have regular step") +def i_should_have_regular_step(test_value): + test_value["value"] += 1 From da104295f230f143d519f1d2b753174c5892a4ad Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 29 Jan 2020 22:03:51 +0100 Subject: [PATCH 06/26] add TODO --- tests/asyncio/test_launching_app_in_background.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index b116d170..0b2df6d1 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -19,6 +19,7 @@ async def i_post_input_variable(value, dummy_server_host, unused_tcp_port): await session.put(endpoint, json={"value": value}) +# TODO: instead of waiting here, add loop to "then" step to request GET every 0.1s @when(parsers.parse("i wait {seconds:d} second(s)")) async def i_wait(seconds): await asyncio.sleep(seconds) From 4262c40d78694e85373e5f1a76f50c4cb21ee42e Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:23:36 +0100 Subject: [PATCH 07/26] add pooling instead of wait step --- .../launching_app_in_background.feature | 1 - .../test_launching_app_in_background.py | 30 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/asyncio/launching_app_in_background.feature b/tests/asyncio/launching_app_in_background.feature index 930e1b87..4bd7f7fa 100644 --- a/tests/asyncio/launching_app_in_background.feature +++ b/tests/asyncio/launching_app_in_background.feature @@ -3,5 +3,4 @@ Feature: Launching application in async task Scenario: App is running during whole scenario Given i have launched app When i post input variable to have value of 3 - And i wait 1 second(s) Then output value should be equal to 4 diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index 0b2df6d1..f80f915c 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -1,4 +1,5 @@ import asyncio +from datetime import datetime, timedelta import aiohttp @@ -19,15 +20,22 @@ async def i_post_input_variable(value, dummy_server_host, unused_tcp_port): await session.put(endpoint, json={"value": value}) -# TODO: instead of waiting here, add loop to "then" step to request GET every 0.1s -@when(parsers.parse("i wait {seconds:d} second(s)")) -async def i_wait(seconds): - await asyncio.sleep(seconds) - - -@then(parsers.parse("output value should be equal to {value:d}")) -async def output_value_should_be_equal_to(value, dummy_server_host, unused_tcp_port): +@then(parsers.parse("output value should be equal to {expected_value:d}")) +async def output_value_should_be_equal_to(expected_value, dummy_server_host, unused_tcp_port): async with aiohttp.ClientSession() as session: - response = await session.get("http://{}:{}/post-computation-value".format(dummy_server_host, unused_tcp_port)) - output_value = int(await response.text()) - assert output_value == value + timeout = 1 + end_time = datetime.now() + timedelta(seconds=timeout) + + while datetime.now() < end_time: + url = "http://{}:{}/post-computation-value".format(dummy_server_host, unused_tcp_port) + response = await session.get(url) + output_value = int(await response.text()) + + if output_value == expected_value: + break + + await asyncio.sleep(0.1) + else: + raise AssertionError( + "Output value of {} isn't equal to expected value of {}.".format(output_value, expected_value) + ) From 5a2f218544ee3578e562a27e7ac54e407157bd9c Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:25:28 +0100 Subject: [PATCH 08/26] add fixture with app tick rate --- tests/asyncio/dummy_app.py | 16 +++++++++++----- .../asyncio/test_launching_app_in_background.py | 6 +++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index 0ddbd599..a2717594 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -90,9 +90,10 @@ class DummyApp: This has to simulate real application that gets input from server, processes it and posts it. """ - def __init__(self, host, port): + 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): @@ -101,14 +102,14 @@ async def run(self): async def run_getter(self): async with aiohttp.ClientSession() as session: while True: - await asyncio.sleep(0.1) + await asyncio.sleep(self.tick_rate_s) response = await session.get("http://{}:{}/pre-computation-value".format(self.host, self.port)) self.stored_value = int(await response.text()) async def run_poster(self): async with aiohttp.ClientSession() as session: while True: - await asyncio.sleep(0.1) + await asyncio.sleep(self.tick_rate_s) await session.put( "http://{}:{}/post-computation-value".format(self.host, self.port), json={"value": self.stored_value + 1}, @@ -127,8 +128,13 @@ def launch_dummy_server(dummy_server_host, unused_tcp_port): @pytest.fixture -async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, unused_tcp_port): - app = DummyApp(dummy_server_host, unused_tcp_port) +def app_tick_rate(): + return 0.1 + + +@pytest.fixture +async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, unused_tcp_port, app_tick_rate): + app = DummyApp(dummy_server_host, unused_tcp_port, app_tick_rate) task = event_loop.create_task(app.run()) yield task.cancel() diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index f80f915c..f4078d89 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -21,9 +21,9 @@ async def i_post_input_variable(value, dummy_server_host, unused_tcp_port): @then(parsers.parse("output value should be equal to {expected_value:d}")) -async def output_value_should_be_equal_to(expected_value, dummy_server_host, unused_tcp_port): +async def output_value_should_be_equal_to(expected_value, dummy_server_host, unused_tcp_port, app_tick_rate): async with aiohttp.ClientSession() as session: - timeout = 1 + timeout = app_tick_rate * 10 end_time = datetime.now() + timedelta(seconds=timeout) while datetime.now() < end_time: @@ -34,7 +34,7 @@ async def output_value_should_be_equal_to(expected_value, dummy_server_host, unu if output_value == expected_value: break - await asyncio.sleep(0.1) + await asyncio.sleep(app_tick_rate) else: raise AssertionError( "Output value of {} isn't equal to expected value of {}.".format(output_value, expected_value) From 9ef9ec525fe44f89f31e3b6908edac88b43e0322 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:26:31 +0100 Subject: [PATCH 09/26] move sleeps in dummy app below rest of the body --- tests/asyncio/dummy_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index a2717594..04120ab7 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -102,18 +102,18 @@ async def run(self): async def run_getter(self): async with aiohttp.ClientSession() as session: while True: - await asyncio.sleep(self.tick_rate_s) 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 asyncio.sleep(self.tick_rate_s) 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 From 9588bf4a01236322c7270484845512720a31e942 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:29:10 +0100 Subject: [PATCH 10/26] add "test_" to feature files names --- .../asyncio/{async_steps.feature => test_async_steps.feature} | 0 tests/asyncio/test_async_steps.py | 4 ++-- ...round.feature => test_launching_app_in_background.feature} | 0 tests/asyncio/test_launching_app_in_background.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename tests/asyncio/{async_steps.feature => test_async_steps.feature} (100%) rename tests/asyncio/{launching_app_in_background.feature => test_launching_app_in_background.feature} (100%) diff --git a/tests/asyncio/async_steps.feature b/tests/asyncio/test_async_steps.feature similarity index 100% rename from tests/asyncio/async_steps.feature rename to tests/asyncio/test_async_steps.feature diff --git a/tests/asyncio/test_async_steps.py b/tests/asyncio/test_async_steps.py index d8fdaa57..2b58105a 100644 --- a/tests/asyncio/test_async_steps.py +++ b/tests/asyncio/test_async_steps.py @@ -8,12 +8,12 @@ def test_value(): return {"value": 0} -@scenario("async_steps.feature", "Async steps are actually executed") +@scenario("test_async_steps.feature", "Async steps are actually executed") def test_async_steps_do_work(test_value): assert test_value["value"] == 3 -@scenario("async_steps.feature", "Async steps are executed along with regular steps") +@scenario("test_async_steps.feature", "Async steps are executed along with regular steps") def test_async_steps_work_with_regular_ones(test_value): assert test_value["value"] == 6 diff --git a/tests/asyncio/launching_app_in_background.feature b/tests/asyncio/test_launching_app_in_background.feature similarity index 100% rename from tests/asyncio/launching_app_in_background.feature rename to tests/asyncio/test_launching_app_in_background.feature diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index f4078d89..79a3421c 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -5,7 +5,7 @@ from pytest_bdd import given, when, then, scenarios, parsers -scenarios("launching_app_in_background.feature") +scenarios("test_launching_app_in_background.feature") @given("i have launched app") From 47e39476019661b5dd031e586d84be52985da827 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:44:50 +0100 Subject: [PATCH 11/26] cleanup starting flask test server --- tests/asyncio/dummy_app.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index 04120ab7..491e133f 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -2,6 +2,7 @@ import contextlib import time from contextlib import contextmanager +from datetime import datetime, timedelta from multiprocessing.context import Process import aiohttp @@ -35,11 +36,11 @@ def setup_and_teardown_flask_app(app: Flask, host: str, port: int): def wait_for_flask_app_to_be_accessible(): timeout = 1 - start_time = time.time() + 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 time.time() - start_time <= timeout: + while response.status_code != HTTP_200_OK and datetime.now() < end_time: with contextlib.suppress(requests.exceptions.ConnectionError): response = requests.request("POST", f"http://{host}:{port}/health") time.sleep(0.01) From 1403c467a22531757e49443499482e0fd1e52609 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 02:45:50 +0100 Subject: [PATCH 12/26] rename and decrease app_tick_rate to app_tick_interval --- tests/asyncio/dummy_app.py | 8 ++++---- tests/asyncio/test_launching_app_in_background.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index 491e133f..c0700b1f 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -129,13 +129,13 @@ def launch_dummy_server(dummy_server_host, unused_tcp_port): @pytest.fixture -def app_tick_rate(): - return 0.1 +def app_tick_interval(): + return 0.01 @pytest.fixture -async def launch_dummy_app(event_loop, launch_dummy_server, dummy_server_host, unused_tcp_port, app_tick_rate): - app = DummyApp(dummy_server_host, unused_tcp_port, app_tick_rate) +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()) yield task.cancel() diff --git a/tests/asyncio/test_launching_app_in_background.py b/tests/asyncio/test_launching_app_in_background.py index 79a3421c..c0e73b78 100644 --- a/tests/asyncio/test_launching_app_in_background.py +++ b/tests/asyncio/test_launching_app_in_background.py @@ -21,9 +21,9 @@ async def i_post_input_variable(value, dummy_server_host, unused_tcp_port): @then(parsers.parse("output value should be equal to {expected_value:d}")) -async def output_value_should_be_equal_to(expected_value, dummy_server_host, unused_tcp_port, app_tick_rate): +async def output_value_should_be_equal_to(expected_value, dummy_server_host, unused_tcp_port, app_tick_interval): async with aiohttp.ClientSession() as session: - timeout = app_tick_rate * 10 + timeout = app_tick_interval * 10 end_time = datetime.now() + timedelta(seconds=timeout) while datetime.now() < end_time: @@ -34,7 +34,7 @@ async def output_value_should_be_equal_to(expected_value, dummy_server_host, unu if output_value == expected_value: break - await asyncio.sleep(app_tick_rate) + await asyncio.sleep(app_tick_interval) else: raise AssertionError( "Output value of {} isn't equal to expected value of {}.".format(output_value, expected_value) From 50ad17331b25ad1c888b7da67f969f5427d22654 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 03:27:01 +0100 Subject: [PATCH 13/26] add tests for given to be a fixture --- .../test_async_given_returns_value.feature | 9 ++++++ .../asyncio/test_async_given_returns_value.py | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/asyncio/test_async_given_returns_value.feature create mode 100644 tests/asyncio/test_async_given_returns_value.py diff --git a/tests/asyncio/test_async_given_returns_value.feature b/tests/asyncio/test_async_given_returns_value.feature new file mode 100644 index 00000000..309f6997 --- /dev/null +++ b/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 diff --git a/tests/asyncio/test_async_given_returns_value.py b/tests/asyncio/test_async_given_returns_value.py new file mode 100644 index 00000000..f1a39ea4 --- /dev/null +++ b/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 + + +@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 + + +@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("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 From 60085b2039cbf7d678165464cadeab148ece864e Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 18:19:10 +0100 Subject: [PATCH 14/26] extract code for running coroutines to function --- pytest_bdd/scenario.py | 12 ++--------- pytest_bdd/utils.py | 42 ++++++++++++++++++++++++++++++++++++- tests/asyncio/test_utils.py | 31 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 tests/asyncio/test_utils.py diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index 2d8f8c6b..f3e58624 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -10,14 +10,12 @@ scenario_name="Publishing the article", ) """ -import asyncio import collections import inspect import os import re import pytest -from _pytest.fixtures import FixtureLookupError try: from _pytest import fixtures as pytest_fixtures @@ -28,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+_*") @@ -116,12 +113,7 @@ def _execute_step_function(request, scenario, step, step_func): # Execute the step. result_or_coro = step_func(**kwargs) - if inspect.iscoroutine(result_or_coro): - try: - event_loop = request.getfixturevalue("event_loop") - except FixtureLookupError: - raise ValueError("Install pytest-asyncio plugin to run asynchronous steps.") - event_loop.run_until_complete(result_or_coro) + run_coroutines(result_or_coro, request=request) request.config.hook.pytest_bdd_after_step(**kw) except Exception as exception: diff --git a/pytest_bdd/utils.py b/pytest_bdd/utils.py index 879f282b..0f2f6d00 100644 --- a/pytest_bdd/utils.py +++ b/pytest_bdd/utils.py @@ -1,7 +1,8 @@ """Various utility functions.""" - import inspect +from _pytest.fixtures import FixtureLookupError + CONFIG_STACK = [] @@ -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) diff --git a/tests/asyncio/test_utils.py b/tests/asyncio/test_utils.py new file mode 100644 index 00000000..6c5aae43 --- /dev/null +++ b/tests/asyncio/test_utils.py @@ -0,0 +1,31 @@ +import pytest + +from pytest_bdd.utils import run_coroutines + + +def regular_fn(): + return 24 + + +async def async_fn(): + return 42 + + +@pytest.mark.parametrize( + ["functions_to_execute", "expected_results"], + [ + (regular_fn(), 24), + (async_fn(), 42), + ((regular_fn(), regular_fn(), regular_fn()), (24, 24, 24)), + ((async_fn(), async_fn(), async_fn()), (42, 42, 42)), + ((regular_fn(), async_fn()), (24, 42)), + ], + ids=["single regular fn", "single async fn", "many regular fns", "many async fns", "mixes fns"], +) +def test_run_coroutines(request, functions_to_execute, expected_results): + if isinstance(functions_to_execute, tuple): + actual_results = run_coroutines(*functions_to_execute, request=request) + else: + actual_results = run_coroutines(functions_to_execute, request=request) + + assert actual_results == expected_results From 5362eef19b1f32142fdd42349fffc09eae5d0b8a Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 3 Feb 2020 20:02:06 +0100 Subject: [PATCH 15/26] cleanup --- tests/asyncio/test_async_given_returns_value.py | 10 +++++----- tests/asyncio/test_utils.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/asyncio/test_async_given_returns_value.py b/tests/asyncio/test_async_given_returns_value.py index f1a39ea4..e30695df 100644 --- a/tests/asyncio/test_async_given_returns_value.py +++ b/tests/asyncio/test_async_given_returns_value.py @@ -15,16 +15,16 @@ async def i_have_given_that_shadows_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 - - @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 diff --git a/tests/asyncio/test_utils.py b/tests/asyncio/test_utils.py index 6c5aae43..6e1afd55 100644 --- a/tests/asyncio/test_utils.py +++ b/tests/asyncio/test_utils.py @@ -20,7 +20,7 @@ async def async_fn(): ((async_fn(), async_fn(), async_fn()), (42, 42, 42)), ((regular_fn(), async_fn()), (24, 42)), ], - ids=["single regular fn", "single async fn", "many regular fns", "many async fns", "mixes fns"], + ids=["single regular fn", "single async fn", "many regular fns", "many async fns", "mixed fns"], ) def test_run_coroutines(request, functions_to_execute, expected_results): if isinstance(functions_to_execute, tuple): From e4020aeb22856eeef64a2fbe46bcce42e31f0281 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 3 Feb 2020 20:25:19 +0100 Subject: [PATCH 16/26] add tests for checking if async scenario is launched --- tests/asyncio/test_async_scenario_function.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/asyncio/test_async_scenario_function.py diff --git a/tests/asyncio/test_async_scenario_function.py b/tests/asyncio/test_async_scenario_function.py new file mode 100644 index 00000000..9c4cdbe7 --- /dev/null +++ b/tests/asyncio/test_async_scenario_function.py @@ -0,0 +1,54 @@ +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) + + +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) From 4e9bef567f49603508f56e1dbc310ed103b05c85 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 9 Mar 2020 19:42:24 +0100 Subject: [PATCH 17/26] add test requirements --- requirements-testing.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements-testing.txt b/requirements-testing.txt index 932a8957..b51942bb 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -1 +1,5 @@ mock +requests +flask_api +aiohttp +pytest-asyncio From 6fc934028bb8ed6cf103e92c8d20b1633fd20d82 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 9 Mar 2020 20:09:35 +0100 Subject: [PATCH 18/26] delete test --- tests/asyncio/test_async_scenario_function.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/asyncio/test_async_scenario_function.py b/tests/asyncio/test_async_scenario_function.py index 9c4cdbe7..11877d6b 100644 --- a/tests/asyncio/test_async_scenario_function.py +++ b/tests/asyncio/test_async_scenario_function.py @@ -34,21 +34,3 @@ async def test_launching_scenario_function(): result = testdir.runpytest() result.assert_outcomes(passed=1) - - -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) From aafa8d4b5c42badd3733f8398e9af91fd8a5621f Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 9 Mar 2020 20:24:21 +0100 Subject: [PATCH 19/26] add support for python 3.6 and 3.5 --- requirements-testing.txt | 1 + tests/asyncio/dummy_app.py | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index b51942bb..6dd85296 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -3,3 +3,4 @@ requests flask_api aiohttp pytest-asyncio +async_generator diff --git a/tests/asyncio/dummy_app.py b/tests/asyncio/dummy_app.py index c0700b1f..c16d1969 100644 --- a/tests/asyncio/dummy_app.py +++ b/tests/asyncio/dummy_app.py @@ -8,6 +8,7 @@ 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 @@ -42,10 +43,10 @@ def wait_for_flask_app_to_be_accessible(): while response.status_code != HTTP_200_OK and datetime.now() < end_time: with contextlib.suppress(requests.exceptions.ConnectionError): - response = requests.request("POST", f"http://{host}:{port}/health") + response = requests.request("POST", "http://{}:{}/health".format(host, port)) time.sleep(0.01) - fail_message = f"Timeout expired: failed to start mock REST API in {timeout} seconds" + 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") @@ -134,9 +135,10 @@ def app_tick_interval(): @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()) - yield + await yield_(None) task.cancel() await asyncio.sleep(0) From cd7562e6ee911451a795d2082648d363828937bb Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 9 Mar 2020 20:45:33 +0100 Subject: [PATCH 20/26] add test back and skip it if pytest < 5.1.0 --- tests/asyncio/test_async_scenario_function.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/asyncio/test_async_scenario_function.py b/tests/asyncio/test_async_scenario_function.py index 11877d6b..7718380c 100644 --- a/tests/asyncio/test_async_scenario_function.py +++ b/tests/asyncio/test_async_scenario_function.py @@ -34,3 +34,28 @@ async def test_launching_scenario_function(): 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) From 1ab24b8cab435142223b63203472c1d75dab093e Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 11 Mar 2020 16:49:29 +0100 Subject: [PATCH 21/26] add 'using asyncio' section to docs --- README.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.rst b/README.rst index d33ad27d..4cc2f45c 100644 --- a/README.rst +++ b/README.rst @@ -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 ----- From 4eaa6a3c9670623663793008b37bcc219f193f79 Mon Sep 17 00:00:00 2001 From: amadeusz Date: Thu, 12 Mar 2020 10:35:35 +0100 Subject: [PATCH 22/26] add async_generator to setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 05a51122..dcacb019 100755 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pytest11": ["pytest-bdd = pytest_bdd.plugin"], "console_scripts": ["pytest-bdd = pytest_bdd.scripts:main"], }, - tests_require=["tox", "flask", "requests", "flask_api", "aiohttp", "pytest-asyncio"], + tests_require=["tox", "flask", "requests", "flask_api", "aiohttp", "pytest-asyncio", "async_generator"], packages=["pytest_bdd"], include_package_data=True, ) From 37d9168077f7a5198e4eb5ea501dad9712b93758 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Sun, 2 Feb 2020 20:54:16 +0100 Subject: [PATCH 23/26] make async hooks be executed --- pytest_bdd/scenario.py | 24 ++++++++++++++++-------- tests/asyncio/conftest.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/pytest_bdd/scenario.py b/pytest_bdd/scenario.py index f3e58624..091f3f19 100644 --- a/pytest_bdd/scenario.py +++ b/pytest_bdd/scenario.py @@ -101,7 +101,7 @@ 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: @@ -109,15 +109,15 @@ def _execute_step_function(request, scenario, step, step_func): 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. result_or_coro = step_func(**kwargs) run_coroutines(result_or_coro, request=request) - request.config.hook.pytest_bdd_after_step(**kw) + 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 @@ -129,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() @@ -138,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: @@ -154,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, @@ -163,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"]) diff --git a/tests/asyncio/conftest.py b/tests/asyncio/conftest.py index f61bab62..a48127d1 100644 --- a/tests/asyncio/conftest.py +++ b/tests/asyncio/conftest.py @@ -1 +1,34 @@ from tests.asyncio.dummy_app import * + + +# TODO: remove below functions and add test to them instead +async def pytest_bdd_before_scenario(request, feature, scenario): + print ("i'm in before scenario") + + +async def pytest_bdd_before_step(request, feature, scenario, step, step_func): + print ("i'm in before step") + + +async def pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args): + print ("i'm in before step call") + + +async def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args): + print ("i'm in after step") + + +async def pytest_bdd_after_scenario(request, feature, scenario): + print ("i'm in after scenario") + + +async def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception): + print ("i'm in step error") + + +async def pytest_bdd_step_validation_error(request, feature, scenario, step, step_func, step_func_args, exception): + print ("i'm in step validation") + + +async def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception): + print ("i'm in step func lookup error") From 2a9e575b92ad2c26185ba54d03593d7baf1ec6e9 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 3 Feb 2020 21:32:36 +0100 Subject: [PATCH 24/26] add tests for async hooks --- tests/asyncio/test_async_hooks.py | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 tests/asyncio/test_async_hooks.py diff --git a/tests/asyncio/test_async_hooks.py b/tests/asyncio/test_async_hooks.py new file mode 100644 index 00000000..efa510ae --- /dev/null +++ b/tests/asyncio/test_async_hooks.py @@ -0,0 +1,166 @@ +import textwrap + +import pytest + + +@pytest.fixture +def feature_file(testdir): + testdir.makefile( + ".feature", + test=textwrap.dedent( + """ + Feature: Async hooks are being launched + + Scenario: Launching async hooks + Given i have step + And i have another step + """ + ), + ) + + +@pytest.fixture +def hook_file(testdir): + testdir.makeconftest( + textwrap.dedent( + """ + async def pytest_bdd_before_scenario(request, feature, scenario): + print("\\npytest_bdd_before_scenario") + + async def pytest_bdd_after_scenario(request, feature, scenario): + print("\\npytest_bdd_after_scenario") + + async def pytest_bdd_before_step(request, feature, scenario, step, step_func): + print("\\npytest_bdd_before_step") + + async def pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args): + print("\\npytest_bdd_before_step_call") + + async def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args): + print("\\npytest_bdd_after_step") + + async def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception): + print("\\npytest_bdd_step_error") + + async def pytest_bdd_step_validation_error(request, feature, scenario, step, step_func, step_func_args, + exception): + print("\\npytest_bdd_step_validation_error") + + async def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception): + print("\\npytest_bdd_step_func_lookup_error") + """ + ) + ) + + +def test_async_non_error_hooks_are_being_launched(feature_file, hook_file, testdir): + testdir.makepyfile( + textwrap.dedent( + """ + import pytest + from pytest_bdd import scenario, given + + @pytest.mark.asyncio + @scenario('test.feature', 'Launching async hooks') + def test_launching_async_hooks(): + pass + + @given("i have step") + def i_have_step(): + pass + """ + ) + ) + + result = testdir.runpytest("-s") + + assert result.stdout.lines.count("pytest_bdd_before_scenario") == 1 + assert result.stdout.lines.count("pytest_bdd_after_scenario") == 1 + assert result.stdout.lines.count("pytest_bdd_before_step") == 1 + assert result.stdout.lines.count("pytest_bdd_before_step_call") == 1 + assert result.stdout.lines.count("pytest_bdd_after_step") == 1 + + +def test_async_step_func_lookup_error_hook_is_being_launched(feature_file, hook_file, testdir): + testdir.makepyfile( + textwrap.dedent( + """ + import pytest + from pytest_bdd import scenario, given + + @pytest.mark.asyncio + @scenario('test.feature', 'Launching async hooks') + def test_launching_async_hooks(): + pass + """ + ) + ) + + result = testdir.runpytest("-s") + + assert result.stdout.lines.count("pytest_bdd_step_func_lookup_error") == 1 + + +def test_async_step_error_hook_is_being_launched(feature_file, hook_file, testdir): + testdir.makepyfile( + textwrap.dedent( + """ + import pytest + from pytest_bdd import scenario, given + + @pytest.mark.asyncio + @scenario('test.feature', 'Launching async hooks') + def test_launching_async_hooks(): + pass + + @given("i have step") + def i_have_step(): + raise Exception() + """ + ) + ) + + result = testdir.runpytest("-s") + + assert result.stdout.lines.count("pytest_bdd_step_error") == 1 + + +def test_async_step_validation_error_hook_is_being_launched(hook_file, testdir): + testdir.makefile( + ".feature", + test=textwrap.dedent( + """ + Feature: Async hooks are being launched + + Scenario: Launching async hooks + Given i have step + And i have another step + """ + ), + ) + + testdir.makepyfile( + textwrap.dedent( + """ + import pytest + from pytest_bdd import scenario, given + + @pytest.mark.asyncio + @scenario('test.feature', 'Launching async hooks') + def test_launching_async_hooks(): + pass + + @given("i have step") + def i_have_step(): + pass + + @given("i have another step") + def i_have_step(): + pass + """ + ) + ) + + result = testdir.runpytest("-s") + + assert result.stdout.lines.count("pytest_bdd_step_validation_error") == 1 From c95304cda75346940850f4e7d6da9a49d2479b33 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Mon, 9 Mar 2020 20:51:46 +0100 Subject: [PATCH 25/26] remove unused hooks from conftest --- tests/asyncio/conftest.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/tests/asyncio/conftest.py b/tests/asyncio/conftest.py index a48127d1..f61bab62 100644 --- a/tests/asyncio/conftest.py +++ b/tests/asyncio/conftest.py @@ -1,34 +1 @@ from tests.asyncio.dummy_app import * - - -# TODO: remove below functions and add test to them instead -async def pytest_bdd_before_scenario(request, feature, scenario): - print ("i'm in before scenario") - - -async def pytest_bdd_before_step(request, feature, scenario, step, step_func): - print ("i'm in before step") - - -async def pytest_bdd_before_step_call(request, feature, scenario, step, step_func, step_func_args): - print ("i'm in before step call") - - -async def pytest_bdd_after_step(request, feature, scenario, step, step_func, step_func_args): - print ("i'm in after step") - - -async def pytest_bdd_after_scenario(request, feature, scenario): - print ("i'm in after scenario") - - -async def pytest_bdd_step_error(request, feature, scenario, step, step_func, step_func_args, exception): - print ("i'm in step error") - - -async def pytest_bdd_step_validation_error(request, feature, scenario, step, step_func, step_func_args, exception): - print ("i'm in step validation") - - -async def pytest_bdd_step_func_lookup_error(request, feature, scenario, step, exception): - print ("i'm in step func lookup error") From 2054d235d40272c541c40e2f6081748ef5c98b87 Mon Sep 17 00:00:00 2001 From: Amadeusz Hercog Date: Wed, 11 Mar 2020 16:52:04 +0100 Subject: [PATCH 26/26] add 'async hooks' section to readme --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index 4cc2f45c..b3c57e07 100644 --- a/README.rst +++ b/README.rst @@ -1138,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 ---------------