csernazs / pytest-httpserver

Http server for pytest to test http clients
MIT License
209 stars 27 forks source link

Multiple responses per endpoint #302

Open hynek opened 4 months ago

hynek commented 4 months ago

Hi, thanks for the great project! There's one thing that keeps tripping me up and that's when I want to return multiple responses after each request. For example, to exercise retries.

Currently I'm using the following pattern:

        resps = [...]

        def respond(req):
            return resps.pop(0)

        httpserver.expect_request("/").respond_with_handler(
            respond
        )

Which can be generalized to:

@attrs.define
class MultipleResponsesHandler:
    _responses: list[Response]

    def __call__(self, _):
        return self._responses.pop(0)

httpserver.expect_request("/").respond_with_handler(
    MultipleResponsesHandler(...)
)

Is there a more elegant way to achieve this and/or should this be part of pytest-httpserver?

csernazs commented 4 months ago

hi @hynek ,

I think you can achieve this by registering multiple oneshot requests. These are then pulled from the queue one by one:

import requests

from pytest_httpserver import HTTPServer

def test_multi_response(httpserver: HTTPServer):
    httpserver.expect_oneshot_request("/foo").respond_with_data("FIRST")
    httpserver.expect_oneshot_request("/foo").respond_with_data("SECOND")

    assert requests.get(httpserver.url_for("/foo")).text == "FIRST"
    assert requests.get(httpserver.url_for("/foo")).text == "SECOND"

The 3rd request for this URL would result in 500, but you can register a fallback method by a "normal" permanent handler:

import requests

from pytest_httpserver import HTTPServer

def test_multi_response(httpserver: HTTPServer):
    httpserver.expect_oneshot_request("/foo").respond_with_data("FIRST")
    httpserver.expect_oneshot_request("/foo").respond_with_data("SECOND")
    httpserver.expect_request("/foo").respond_with_data("FALLBACK")

    assert requests.get(httpserver.url_for("/foo")).text == "FIRST"
    assert requests.get(httpserver.url_for("/foo")).text == "SECOND"

    assert requests.get(httpserver.url_for("/foo")).text == "FALLBACK"
    assert requests.get(httpserver.url_for("/foo")).text == "FALLBACK"

Hope this helps.

Tbh your attrs example looks great also :)

Zsolt

csernazs commented 4 months ago

Hi @hynek ,

Just a friendly ping, have you seen my last comment? Did the way I suggested work for you?

Zsolt

hynek commented 4 months ago

Hi sorry for the delay, I'm traveling, I haven't quite made up my mind about it, but I've also not tried it, because it feels a bit repetitive.

What I think would be really cool it being about to write: httpserver.expect_oneshot_request("/foo").respond_with_data("FIRST").respond_with_data("FIRST") 🤔

But I think I like mine good enough – for those who want to copy paste, my latest version look like this which is a bit more user-friendly:

class MultipleResponsesHandler:
    """
    A pytest-httpserver handler that returns different responses to subsequent
    requests.
    """

    _responses_left: list[Response]

    def __init__(self, responses: Sequence[Response | str | bytes]):
        self._responses_left = [
            resp if isinstance(resp, Response) else Response(resp)
            for resp in responses
        ]

    def __call__(self, _: Request) -> Response:
        return self._responses_left.pop(0)

I'm considering to add request-recording tho, since sometimes httpserver is a bit opaque about what went wrong and I could just accept everything and introspect. 🤔

csernazs commented 4 months ago

hi @hynek ,

I looked at the current code of pytest-httpserver and implementing this fluent api you descibed would be a big refactoring. Not impossible but the code was written to have strictly one expectation for the request and one response.

However the problem you descibed seems to be valid indeed.

What I could imagine as a compromise between the fluent and the one I suggested (I agree it had code repetitions), is something like this:

httpserver.expect_request("/").respond_multiple([
    Response(...),
    Response(...),
])

It would accept an Iterable[Response] so if someone wanted an infinite loop it can be implemented as well (with itertools.cycle or such).

So this would remove the ugly code repetition from my example and also it won't require huge refactorings in the code as well. This is just an idea, I don't want to push anything.

I'm interested in your final code. I'm also interested how you implement the request recording you mentioned.