lundberg / respx

Mock HTTPX with awesome request patterns and response side effects 🦋
https://lundberg.github.io/respx
BSD 3-Clause "New" or "Revised" License
607 stars 39 forks source link

[BUG] `.mock(...)` with `side_effect` and `return_value` raises `StopIteration` error #271

Open jborman-stonex opened 6 months ago

jborman-stonex commented 6 months ago

Environment

Using the latest respx==0.21.1 with pytest==8.0.0 and pytest-mock==3.12.0

Issue

Following the respx docs for mocking a response using both a side_effect and return_value raises a StopIteration error.

While the docs state the following as an example, the test does not actually pass.

Once the iterable is exhausted, the route will fallback and respond with the return_value, if set.

import httpx
import respx

@respx.mock
def test_stacked_responses():
    respx.post("https://example.org/").mock(
        side_effect=[httpx.Response(201)],
        return_value=httpx.Response(200) 
    )

    response1 = httpx.post("https://example.org/")
    response2 = httpx.post("https://example.org/")
    response3 = httpx.post("https://example.org/")

    assert response1.status_code == 201
    assert response2.status_code == 200
    assert response3.status_code == 200

Running the above via pytest I see:

_______________________________________________________________________________________________________________________________________________________________________________________ test_stacked_responses ________________________________________________________________________________________________________________________________________________________________________________________ 

    @respx.mock
    def test_stacked_responses():
        respx.post("https://example.org/").mock(
            side_effect=[httpx.Response(201)],
            return_value=httpx.Response(200)
        )

        response1 = httpx.post("https://example.org/")
>       response2 = httpx.post("https://example.org/")

tests\test_client.py:82:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.venv\Lib\site-packages\httpx\_api.py:319: in post
    return request(
.venv\Lib\site-packages\httpx\_api.py:106: in request
    return client.request(
.venv\Lib\site-packages\httpx\_client.py:827: in request
    return self.send(request, auth=auth, follow_redirects=follow_redirects)
.venv\Lib\site-packages\httpx\_client.py:914: in send
    response = self._send_handling_auth(
.venv\Lib\site-packages\httpx\_client.py:942: in _send_handling_auth
    response = self._send_handling_redirects(
.venv\Lib\site-packages\httpx\_client.py:979: in _send_handling_redirects
    response = self._send_single_request(request)
.venv\Lib\site-packages\httpx\_client.py:1015: in _send_single_request
    response = transport.handle_request(request)
.venv\Lib\site-packages\httpx\_transports\default.py:233: in handle_request
    resp = self._pool.handle_request(req)
.venv\Lib\site-packages\respx\mocks.py:181: in mock
    response = cls._send_sync_request(
.venv\Lib\site-packages\respx\mocks.py:212: in _send_sync_request
    httpx_response = cls.handler(httpx_request)
.venv\Lib\site-packages\respx\mocks.py:113: in handler
    httpx_response = router.handler(httpx_request)
.venv\Lib\site-packages\respx\router.py:313: in handler
    resolved = self.resolve(request)
.venv\Lib\site-packages\respx\router.py:279: in resolve
    prospect = route.match(request)
.venv\Lib\site-packages\respx\models.py:426: in match
    result = self.resolve(request, **context)
.venv\Lib\site-packages\respx\models.py:391: in resolve
    result = self._resolve_side_effect(request, **kwargs)
.venv\Lib\site-packages\respx\models.py:362: in _resolve_side_effect
    effect = self._next_side_effect()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <Route <Scheme eq 'https'> AND <Host eq 'example.org'> AND <Path eq '/'> AND <Method eq 'POST'>>

    def _next_side_effect(
        self,
    ) -> Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]:
        assert self._side_effect is not None
        effect: Union[CallableSideEffect, Exception, Type[Exception], httpx.Response]
        if isinstance(self._side_effect, Iterator):
>           effect = next(self._side_effect)
E           StopIteration

.venv\Lib\site-packages\respx\models.py:323: StopIteration

The above exception was the direct cause of the following exception:

cls = <class '_pytest.runner.CallInfo'>, func = <function call_runtest_hook.<locals>.<lambda> at 0x000002207332DC60>, when = 'call', reraise = (<class '_pytest.outcomes.Exit'>, <class 'KeyboardInterrupt'>)

    @classmethod
    def from_call(
        cls,
        func: Callable[[], TResult],
        when: Literal["collect", "setup", "call", "teardown"],
        reraise: Optional[
            Union[Type[BaseException], Tuple[Type[BaseException], ...]]
        ] = None,
    ) -> "CallInfo[TResult]":
        """Call func, wrapping the result in a CallInfo.

        :param func:
            The function to call. Called without arguments.
        :param when:
            The phase in which the function is called.
        :param reraise:
            Exception or exceptions that shall propagate if raised by the
            function, instead of being wrapped in the CallInfo.
        """
        excinfo = None
        start = timing.time()
        precise_start = timing.perf_counter()
        try:
>           result: Optional[TResult] = func()

.venv\Lib\site-packages\_pytest\runner.py:345:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
.venv\Lib\site-packages\_pytest\runner.py:266: in <lambda>
    lambda: ihook(item=item, **kwds), when=when, reraise=reraise
.venv\Lib\site-packages\pluggy\_hooks.py:501: in __call__
    return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
.venv\Lib\site-packages\pluggy\_manager.py:119: in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
.venv\Lib\site-packages\_pytest\threadexception.py:87: in pytest_runtest_call
    yield from thread_exception_runtest_hook()
.venv\Lib\site-packages\_pytest\threadexception.py:63: in thread_exception_runtest_hook
    yield
.venv\Lib\site-packages\_pytest\unraisableexception.py:90: in pytest_runtest_call
    yield from unraisable_exception_runtest_hook()
.venv\Lib\site-packages\_pytest\unraisableexception.py:65: in unraisable_exception_runtest_hook
    yield
.venv\Lib\site-packages\_pytest\logging.py:839: in pytest_runtest_call
    yield from self._runtest_for(item, "call")
.venv\Lib\site-packages\_pytest\logging.py:822: in _runtest_for
    yield
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <CaptureManager _method='fd' _global_capturing=<MultiCapture out=<FDCapture 1 oldfd=9 _state='suspended' tmpfile=<_io...._io.TextIOWrapper name='nul' mode='r' encoding='utf-8'>> _state='suspended' _in_suspended=False> _capture_fixture=None>, item = <Function test_stacked_responses>

    @hookimpl(wrapper=True)
    def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
        with self.item_capture("call", item):
>           return (yield)
E           RuntimeError: generator raised StopIteration

.venv\Lib\site-packages\_pytest\capture.py:882: RuntimeError

Expectation

I would expect respx to catch this StopIteration error when a return_value is specified and return that value instead of propagating the error.

lundberg commented 6 months ago

This is not a bug, and instead by design to align with how pythons existing unittest Mock behave.

Though, I can kind-of agree with you that it would be a nicer experience if it would work like you suggest, but I think it's better and cleaner if it works like the already familiar mock api.

To get your desired outcome, you could utilize itertools, e.g.

from itertools import chain, repeat

respx.post("https://example.org/").mock(
    side_effect=chain(
        [httpx.Response(201)],
        repeat(httpx.Response(200)),
    ),
)