Skip to content

Commit

Permalink
pytest_httpserver: extend HTTPServer to support writing behave test s…
Browse files Browse the repository at this point in the history
…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
matez0 committed Aug 28, 2022
1 parent e75910c commit 29a012a
Show file tree
Hide file tree
Showing 4 changed files with 287 additions and 0 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ Similar to requests, you can fine-tune what response you want to send:
- data


#### Behave support

Using the `BlockingHttpServer` class, the assertion for a request and the response can be performed in real order.
For more info, see the [test](tests/test_blocking_http_server.py) and the API documentation.


### Missing features
* HTTP/2
* Keepalive
Expand Down
2 changes: 2 additions & 0 deletions pytest_httpserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"URIPattern",
"URI_DEFAULT",
"METHOD_ALL",
"BlockingHttpServer",
]

from .httpserver import METHOD_ALL
Expand All @@ -25,3 +26,4 @@
from .httpserver import RequestHandler
from .httpserver import URIPattern
from .httpserver import WaitingSettings
from .blocking_http_server import BlockingHttpServer
156 changes: 156 additions & 0 deletions pytest_httpserver/blocking_http_server.py
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]
123 changes: 123 additions & 0 deletions tests/test_blocking_http_server.py
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

0 comments on commit 29a012a

Please sign in to comment.