respx icon indicating copy to clipboard operation
respx copied to clipboard

Regression of ASGI mocking after `respx == 0.17.1` and `httpx == 0.19.0`

Open Skeen opened this issue 3 years ago • 2 comments
trafficstars

We run code alike this:

import httpx
import pytest
import respx
from respx.mocks import HTTPCoreMocker


@pytest.mark.asyncio
async def test_asgi():
    try:
        HTTPCoreMocker.add_targets(
            "httpx._transports.asgi.ASGITransport",
            "httpx._transports.wsgi.WSGITransport",
        )
        async with respx.mock:
            async with httpx.AsyncClient(app="fake-asgi") as client:
                url = "https://foo.bar/"
                jzon = {"status": "ok"}
                headers = {"X-Foo": "bar"}
                request = respx.get(url) % dict(
                    status_code=202, headers=headers, json=jzon
                )
                response = await client.get(url)
                assert request.called is True
                assert response.status_code == 202
                assert response.headers == httpx.Headers(
                    {
                        "Content-Type": "application/json",
                        "Content-Length": "16",
                        **headers,
                    }
                )
                assert response.json() == {"status": "ok"}
    finally:
        HTTPCoreMocker.remove_targets(
            "httpx._transports.asgi.ASGITransport",
            "httpx._transports.wsgi.WSGITransport",
        )

It works perfectly fine using respx == 0.17.1 and httpx == 0.19.0, as can be seen by:

$> pytest test.py
=================== test session starts ===================
platform linux -- Python 3.10.5, pytest-7.1.2, pluggy-1.0.0
plugins: asyncio-0.19.0, respx-0.17.1, anyio-3.6.1
asyncio: mode=strict
collected 1 item                                          

test.py .                                           [100%]

==================== 1 passed in 0.00s ====================

However upgrading httpx == 0.20.0 yields:

.../lib/python3.10/site-packages/respx/mocks.py:179: in amock
    request = cls.to_httpx_request(**kwargs)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

cls = <class 'respx.mocks.HTTPCoreMocker'>, kwargs = {'request': <Request('GET', 'https://foo.bar/')>}

    @classmethod
    def to_httpx_request(cls, **kwargs):
        """
        Create a `HTTPX` request from transport request args.
        """
        request = (
>           kwargs["method"],
            kwargs["url"],
            kwargs.get("headers"),
            kwargs.get("stream"),
        )
E       KeyError: 'method'

.../lib/python3.10/site-packages/respx/mocks.py:288: KeyError

While trying to upgrade respx == 0.18.0 yields a package resolution error:

  SolverProblemError

  Because respx (0.18.0) depends on httpx (>=0.20.0)
   and respxbug depends on httpx (^0.19.0), respx is forbidden.
  So, because respxbug depends on respx (0.18.0), version solving failed.

Upgrading both yields an error alike the one for just upgrading respx.

Running with respx == 0.19.2 and httpx == 0.23.0 (the newest version at the time for writing), yields:

.../lib/python3.10/site-packages/respx/mocks.py:186: in amock
    request = cls.to_httpx_request(**kwargs))
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

cls = <class 'respx.mocks.HTTPCoreMocker'>, kwargs = {'request': <Request('GET', 'https://foo.bar/')>}, request = <Request('GET', 'https://foo.bar/')>

    @classmethod
    def to_httpx_request(cls, **kwargs):
        """
        Create a `HTTPX` request from transport request arg.
        """
        request = kwargs["request"]
        raw_url = (
            request.url.scheme,
            request.url.host,
            request.url.port,
>           request.url.target,
        )
E       AttributeError: 'URL' object has no attribute 'target'

.../lib/python3.10/site-packages/respx/mocks.py:302: AttributeError

I have attempted to debug the issue, and it seems there's a difference in the incoming request.url object type during ASGI and non-ASGI mocking (see the example below).

With ASGI mocking:

(Pdb) pp request.url
URL('https://foo.bar/')
(Pdb) pp type(request.url)
<class 'httpx.URL'>

Without ASGI mocking (i.e. ordinary mocking):

(Pdb) pp request.url
URL(scheme=b'https', host=b'foo.bar', port=None, target=b'/')
(Pdb) type(request.url)
<class 'httpcore.URL'>

The non-ASGI mocking case was produced with the exact same code as above, but by changing:

            async with httpx.AsyncClient(app="fake-asgi") as client:

to:

            async with httpx.AsyncClient() as client:

I have not studied the flow of respx mock well enough to know how to implement an appropriate fix, but the problem seems to arise from the fact that .target is not a documented member on httpxs URL object, however it does have similar code to the above, here, which utilizes .raw_path instead of .target.

So maybe the code ought to branch on the incoming URL type and provide url.raw if the type is a httpx.URL, unless it can be passed on directly?

Finally it seems like the test validating that ASGI mocking works was removed in this commit: 47c0b935176e081a3aa7886aed8b8ed31c0e9457, while the core functionality was added in this PR: #131 (along with said test).

As the code stands now it only seems to test that the "httpx._transports.asgi.ASGITransport" can be added and removed using HTTPCoreMocker.add_targets and HTTPCoreMocker.remove_targets not that mocking with the ASGITransport actually works.

Skeen avatar Jul 26 '22 09:07 Skeen

Thanks @Skeen for the issue and research!

It's true that the ASGI mocking lacks logic testing, will have to look at this next week 👍

lundberg avatar Jul 26 '22 12:07 lundberg

I've been looking at this now, and the HTTPCoreMocker.add_targets that you're using is not a documented feature and only available for adding httpcore transports, you're adding a httpx transport which is not dealing the same kind of request/response models. That's why stuff breaks 😉.

To mock an ASGI app you can use the documented respx.ASGIHandler.

Though, you're instantiating the httpx client with app="fake-asgi", which will not work with the ASGIHandler, since it needs a real app to route the requests to, to get the mocked views/responses.

I would need some more info for your usecase to help further, e.g. why use a client without a real app, or why the app at all?

lundberg avatar Aug 01 '22 15:08 lundberg

Hi @lundberg

The code presented in this PR is copied from here: https://github.com/lundberg/respx/blob/0.17.1/tests/test_mock.py#L462-L493 It's code from a test that used to be included in this projects test-suite, that ensured that ASGITransport mocking was working.

I was thinking that the test ought to be reintroduced to prove that this entire HTTPCoreMocker.add_targets / HTTPCoreMocker.remove_targets functionality is working in practice, and not only that it is possible to add and remove transports.

It is correct that it is adding a httpx transport rather than a httpcore transport, but I merely followed the instructions here: #131

Which state, and I quote:

@lundberg said:

If anyone needs the current behaviour, patching asgi and/or wsgi transports, one now needs to do:

from respx.mocks import HTTPCoreMocker


HTTPCoreMocker.add_targets(
    "httpx._transports.asgi.ASGITransport",
    "httpx._transports.wsgi.WSGITransport",
)

I suppose this advice has since become out-dated.

Our motivation for mocking the ASGITransport is as follows:

We have a program, that consist of multiple interoperating micro-service applications. All of these micro-service applications are unit-tested using respx mocking, such that the applications can be tested in isolation without depending on their backing services.

As an example, we currently create our test-client within one application's unit-test suite, with: test_client = httpx.AsyncClient(app=app) and inside the application itself we create a client to call a backing service: backing_service_client = httpx.AsyncClient(base_url="http://backing_service")

Now this works lovely, and having mocked the calls to "http://backing_service" ensures that we can test our application in isolation, without actually making calls to the backing service.

However, we have found that two of our micro-services are actually not meaningfully decoupled, as thus that they should actually be one micro-service instead. We are therefore in the process of merging two ASGI applications, that used to be deployed separately.

We have merged the source-code of the two applications, and we now create the backing_service_client using: backing_service_client = httpx.AsyncClient(app=merged_app, base_url="http://backing_service"), such that calls go directly from one ASGI application into the other without ever generating network traffic.

We would like to keep using the respx mocks for backing_service_client in our unit-test suite, such that the test-suite behaves exactly as it did before app=merged_app was added to the backing_service_client. We can achieve this by utilizing respx == 0.17.1 and httpx == 0.19.0 with code alike the snippet in the original post, to add the ASGITransport to the mocking targets, but it does not work for later versions.

As you can see, the documented ASGIHandler does not fulfill our use-case, as we do not actually wanna mock calls with an ASGI app, but rather we wanna mock calls into an ASGI app, to ensure that our test-suite keeps working as it used to.

Skeen avatar Aug 11 '22 08:08 Skeen

The code presented in this PR is copied from here: https://github.com/lundberg/respx/blob/0.17.1/tests/test_mock.py#L462-L493

It's true that it was possible to add HTTPX transports as targets to the HTTPCoreMocker.

As can be seen here in an older version of HTTPX, around the same time of that PR, one can see that httpx transports actually were based on httpcore transports.

As of HTTPX version 0.18, this was changed, and the example in #131 is not valid anymore.

I can see now that the ASGIHandler is not solving your mocking, since it's like you describe for mocking with an ASGI app.

Your httpx client is instantiated with app kwarg, which will make a request to be dispatched and handled by the httpx ASGI transport, which in turn is not handled/patched by the respx HTTPCoreMocker.

By default, RESPX mocks on a httpcore level, but there is an undocumented feature, HTTPXMocker, that makes respx mock on a HTTPX level, i.e. no transports involved like ASGI etc ... this might solve your problem.

To enable this, you'll need to "start" respx with the using param:

@respx.mock(using="httpx", base_url="http://backing_service")
async def some_test(respx_mock):
    respx_mock.get("/some_path").mock(return_value=httpx.Response(204))
    ...

lundberg avatar Aug 11 '22 11:08 lundberg

@Skeen, I'll close this issue. Please re-open if my answer didn't solve your problem.

lundberg avatar Aug 25 '22 20:08 lundberg