-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
pytest_httpserver: extend HTTPServer to support writing behave test s…
…teps in real order HTTPServer is suitable in itself to use in behave tests, but because of the expectation on a request has to be done before the request is performed the test steps of checking the request and sending the response also has to be before the test step triggering the request. This makes a confusing test specification like: Then SUT sends an other request to server When server responds something When a request is sent to SUT Then SUT responds something Using the BlockingHttpServer, the server blocks until the request is asserted and then it blocks again until the response is performed. The test steps can be written in real order: When a request is sent to SUT Then SUT sends an other request to server When server responds something Then SUT responds something
- Loading branch information
Showing
4 changed files
with
287 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,156 @@ | ||
from queue import Empty, Queue | ||
from typing import Any | ||
from typing import Mapping | ||
from typing import Optional | ||
from typing import Pattern | ||
from typing import Union | ||
|
||
from werkzeug.wrappers import Request, Response | ||
|
||
from pytest_httpserver.httpserver import \ | ||
HeaderValueMatcher, HTTPServer, METHOD_ALL, QueryMatcher, RequestHandler, UNDEFINED, URIPattern | ||
|
||
|
||
class BlockingRequestHandler(RequestHandler): | ||
""" | ||
Decorates the :py:class:`RequestHandler` to allow responding synchronously. | ||
This class should only be instantiated inside the implementation of the :py:class:`BlockingHttpServer`. | ||
""" | ||
|
||
def __init__(self, request: Request, response_queue: Queue): | ||
super().__init__(None) | ||
self.request = request | ||
self.response_queue = response_queue | ||
|
||
def respond_with_data(self, *args, **kwargs): | ||
self._respond_via_queue(super().respond_with_data, *args, **kwargs) | ||
|
||
def respond_with_response(self, *args, **kwargs): | ||
self._respond_via_queue(super().respond_with_response, *args, **kwargs) | ||
|
||
def _respond_via_queue(self, respond_method, *args, **kwargs): | ||
respond_method(*args, **kwargs) | ||
self.response_queue.put_nowait(self.respond(self.request)) | ||
|
||
|
||
class BlockingHttpServer(HTTPServer): | ||
""" | ||
Server instance which enables synchronous matching for incoming requests. | ||
:param timeout: waiting time in seconds for matching and responding to an incoming request. | ||
manager | ||
For further parameters and attributes see :py:class:`HTTPServer`. | ||
""" | ||
|
||
def __init__(self, *args, timeout: int = 30, **kwargs): | ||
super().__init__(*args, **kwargs) | ||
self.timeout = timeout | ||
self.request_queue = Queue() | ||
self.request_handlers = {} | ||
|
||
def assert_request( | ||
self, | ||
uri: Union[str, URIPattern, Pattern[str]], | ||
method: str = METHOD_ALL, | ||
data: Union[str, bytes, None] = None, | ||
data_encoding: str = "utf-8", | ||
headers: Optional[Mapping[str, str]] = None, | ||
query_string: Union[None, QueryMatcher, str, bytes, Mapping] = None, | ||
header_value_matcher: Optional[HeaderValueMatcher] = None, | ||
json: Any = UNDEFINED, | ||
timeout: int = 30 | ||
) -> BlockingRequestHandler: | ||
""" | ||
Wait for an incoming request and check whether it matches according to the given parameters. | ||
If the incoming request matches, a request handler is created and registered, | ||
otherwise assertion error is raised. | ||
The request handler can be used once to respond for the request. | ||
If no response is performed in the period given in the timeout parameter of the constructor | ||
or no request arrives in the `timeout` period, assertion error is raised. | ||
:param uri: URI of the request. This must be an absolute path starting with ``/``, a | ||
:py:class:`URIPattern` object, or a regular expression compiled by :py:func:`re.compile`. | ||
:param method: HTTP method of the request. If not specified (or `METHOD_ALL` | ||
specified), all HTTP requests will match. | ||
:param data: payload of the HTTP request. This could be a string (utf-8 encoded | ||
by default, see `data_encoding`) or a bytes object. | ||
:param data_encoding: the encoding used for data parameter if data is a string. | ||
:param headers: dictionary of the headers of the request to be matched | ||
:param query_string: the http query string, after ``?``, such as ``username=user``. | ||
If string is specified it will be encoded to bytes with the encode method of | ||
the string. If dict is specified, it will be matched to the ``key=value`` pairs | ||
specified in the request. If multiple values specified for a given key, the first | ||
value will be used. If multiple values needed to be handled, use ``MultiDict`` | ||
object from werkzeug. | ||
:param header_value_matcher: :py:class:`HeaderValueMatcher` that matches values of headers. | ||
:param json: a python object (eg. a dict) whose value will be compared to the request body after it | ||
is loaded as json. If load fails, this matcher will be failed also. *Content-Type* is not checked. | ||
If that's desired, add it to the headers parameter. | ||
:param timeout: waiting time in seconds for an incoming request. | ||
:return: Created and registered :py:class:`BlockingRequestHandler`. | ||
Parameters `json` and `data` are mutually exclusive. | ||
""" | ||
|
||
matcher = self.create_matcher( | ||
uri, | ||
method=method.upper(), | ||
data=data, | ||
data_encoding=data_encoding, | ||
headers=headers, | ||
query_string=query_string, | ||
header_value_matcher=header_value_matcher, | ||
json=json, | ||
) | ||
|
||
try: | ||
request = self.request_queue.get(timeout=timeout) | ||
except Empty: | ||
raise AssertionError(f'Waiting for request {matcher} timed out') | ||
|
||
diff = matcher.difference(request) | ||
|
||
request_handler = BlockingRequestHandler(request, Queue()) | ||
|
||
self.request_handlers[request].put_nowait(request_handler) | ||
|
||
if diff: | ||
request_handler.respond_with_response(self.respond_nohandler(request)) | ||
raise AssertionError(f'Request {matcher} does not match: {diff}') | ||
|
||
return request_handler | ||
|
||
def dispatch(self, request: Request) -> Response: | ||
""" | ||
Dispatch a request for synchronous matching. | ||
This method queues the request for matching and waits for the request handler. | ||
If there was no request handler, error is responded, | ||
otherwise it waits for the response of request handler. | ||
If no response arrives, assertion error is raised, otherwise the response is returned. | ||
:param request: the request object from the werkzeug library. | ||
:return: the response object what the handler responded, or a response which contains the error. | ||
""" | ||
|
||
self.request_handlers[request] = Queue() | ||
try: | ||
self.request_queue.put_nowait(request) | ||
|
||
try: | ||
request_handler = self.request_handlers[request].get(timeout=self.timeout) | ||
except Empty: | ||
return self.respond_nohandler(request) | ||
|
||
try: | ||
return request_handler.response_queue.get(timeout=self.timeout) | ||
except Empty: | ||
assertion = AssertionError(f"No response for request: {request_handler.request}") | ||
self.add_assertion(assertion) | ||
raise assertion | ||
finally: | ||
del self.request_handlers[request] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
from contextlib import contextmanager | ||
from copy import deepcopy | ||
from multiprocessing import Pool | ||
from time import sleep | ||
from urllib.parse import urlparse | ||
|
||
import pytest | ||
import requests | ||
|
||
from pytest_httpserver import BlockingHttpServer | ||
|
||
|
||
@pytest.fixture | ||
def httpserver(): | ||
server = BlockingHttpServer(timeout=1) | ||
server.start() | ||
|
||
yield server | ||
|
||
server.clear() | ||
if server.is_running(): | ||
server.stop() | ||
|
||
|
||
def test_behave_workflow(httpserver: BlockingHttpServer): | ||
request = dict( | ||
method='GET', | ||
url=httpserver.url_for('/my/path'), | ||
) | ||
|
||
with when_a_request_is_being_sent_to_the_server(request) as server_connection: | ||
|
||
client_connection = then_the_server_gets_the_request(httpserver, request) | ||
|
||
response = {"foo": "bar"} | ||
|
||
when_the_server_responds_to(client_connection, response) | ||
|
||
then_the_response_is_got_from(server_connection, response) | ||
|
||
|
||
def test_raises_assertion_error_when_request_does_not_match(httpserver: BlockingHttpServer): | ||
request = dict( | ||
method='GET', | ||
url=httpserver.url_for('/my/path'), | ||
) | ||
|
||
with when_a_request_is_being_sent_to_the_server(request): | ||
|
||
with pytest.raises(AssertionError) as exc: | ||
httpserver.assert_request(uri='/not/my/path/') | ||
|
||
assert '/not/my/path/' in str(exc) | ||
assert 'does not match' in str(exc) | ||
|
||
|
||
def test_raises_assertion_error_when_request_was_not_sent(httpserver: BlockingHttpServer): | ||
with pytest.raises(AssertionError) as exc: | ||
httpserver.assert_request(uri='/my/path/', timeout=1) | ||
|
||
assert '/my/path/' in str(exc) | ||
assert 'timed out' in str(exc) | ||
|
||
|
||
def test_ignores_when_request_is_not_asserted(httpserver: BlockingHttpServer): | ||
request = dict( | ||
method='GET', | ||
url=httpserver.url_for('/my/path'), | ||
) | ||
|
||
with when_a_request_is_being_sent_to_the_server(request) as server_connection: | ||
|
||
assert server_connection.get(timeout=9).text == 'No handler found for this request' | ||
|
||
|
||
def test_raises_assertion_error_when_request_was_not_responded(httpserver: BlockingHttpServer): | ||
request = dict( | ||
method='GET', | ||
url=httpserver.url_for('/my/path'), | ||
) | ||
|
||
with when_a_request_is_being_sent_to_the_server(request): | ||
|
||
then_the_server_gets_the_request(httpserver, request) | ||
|
||
sleep(1) # sleeping for timeout waiting for the response | ||
|
||
with pytest.raises(AssertionError) as exc: | ||
httpserver.check_assertions() | ||
|
||
assert '/my/path' in str(exc) | ||
assert 'no response' in str(exc).lower() | ||
|
||
|
||
@contextmanager | ||
def when_a_request_is_being_sent_to_the_server(request): | ||
with Pool(1) as pool: | ||
yield pool.apply_async(requests.request, kwds=request) | ||
|
||
|
||
def then_the_server_gets_the_request(server, request): | ||
request = deepcopy(request) | ||
replace_url_with_uri(request) | ||
|
||
return server.assert_request(**request) | ||
|
||
|
||
def replace_url_with_uri(request): | ||
request['uri'] = get_uri(request['url']) | ||
del request['url'] | ||
|
||
|
||
def get_uri(url): | ||
url = urlparse(url) | ||
return '?'.join(item for item in [url.path, url.query] if item) | ||
|
||
|
||
def when_the_server_responds_to(client_connection, response): | ||
client_connection.respond_with_json(response) | ||
|
||
|
||
def then_the_response_is_got_from(server_connection, response): | ||
assert server_connection.get(timeout=9).json() == response |