Repository: lundberg/respx
Pull request #39 (2020-01-17 12:01:46)

Add support for nested and parallel mocking
Sometimes it is useful to set up a separate `respx.mock()` per mocked API.  
This is especially useful when using pytest yield fixtures to set up respx.

Current version does not support this, since each respx mock instance unwantedly (re)patches HTTPX with it's own patterns, and could therefore fail the built-in assertion checks; `assert_all_called` and `assert_all_mocked`.

This **PR** solves this issue without breaking the api.




Patch:
diff --git a/noxfile.py b/noxfile.py
index 4cb6c62..139550a 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -15,7 +15,7 @@ def test(session):
     session.install("-e", ".")
 
     options = session.posargs
-    if "-k" in options:
+    if "-k" in options or "-x" in options:
         options.append("--no-cov")
 
     session.run("pytest", "-v", *options)
diff --git a/respx/__init__.py b/respx/__init__.py
index 0d5abd9..e598e60 100644
--- a/respx/__init__.py
+++ b/respx/__init__.py
@@ -2,7 +2,7 @@ from .__version__ import __version__
 from .api import HTTPXMock
 
 # Expose mock api
-mock = HTTPXMock(assert_all_called=False, local=False)
+mock = HTTPXMock(assert_all_called=False)
 __api__ = list(filter(lambda m: not m.startswith("_"), dir(mock)))
 
 __all__ = ["__version__", "HTTPXMock", "mock"]
diff --git a/respx/api.py b/respx/api.py
index d096940..88a5b37 100644
--- a/respx/api.py
+++ b/respx/api.py
@@ -25,12 +25,13 @@ class HTTPXMock:
         assert_all_called: bool = True,
         assert_all_mocked: bool = True,
         base_url: typing.Optional[str] = None,
-        local: bool = True,
+        proxy: typing.Optional["HTTPXMock"] = None,
     ) -> None:
-        self._is_local = local
         self._assert_all_called = assert_all_called
         self._assert_all_mocked = assert_all_mocked
         self._base_url = base_url
+        self._proxy = proxy
+        self._mocks: typing.List[HTTPXMock] = []
         self._patchers: typing.List[asynctest.mock._patch] = []
         self._patterns: typing.List[RequestPattern] = []
         self.aliases: typing.Dict[str, RequestPattern] = {}
@@ -51,10 +52,14 @@ class HTTPXMock:
         for global state, i.e. shared patterns added outside of scope.
         """
         if func is None:
-            # A. First stage of "local" decorator, WITH parentheses.
-            # B. Only stage of "local" context manager, WITH parentheses,
-            #    "global" context maanager hits __enter__ directly.
-            settings: typing.Dict[str, typing.Any] = {"base_url": base_url}
+            # Parantheses used, branch out to new nested instance.
+            # - Only stage when using local ctx `with respx.mock(...) as httpx_mock:`
+            # - First stage when using local decorator `@respx.mock(...)`
+            #   FYI, global ctx `with respx.mock:` hits __enter__ directly
+            settings: typing.Dict[str, typing.Any] = {
+                "base_url": base_url,
+                "proxy": self,
+            }
             if assert_all_called is not None:
                 settings["assert_all_called"] = assert_all_called
             if assert_all_mocked is not None:
@@ -65,7 +70,7 @@ class HTTPXMock:
         @wraps(func)
         async def async_decorator(*args, **kwargs):
             assert func is not None
-            if self._is_local:
+            if self._proxy:
                 kwargs["httpx_mock"] = self
             async with self:
                 return await func(*args, **kwargs)
@@ -74,37 +79,76 @@ class HTTPXMock:
         @wraps(func)
         def sync_decorator(*args, **kwargs):
             assert func is not None
-            if self._is_local:
+            if self._proxy:
                 kwargs["httpx_mock"] = self
             with self:
                 return func(*args, **kwargs)
 
-        # Dispatch async/sync decorator, depening on decorated function
-        # A. Second stage of "local" decorator, WITH parentheses.
-        # A. Only stage of "global" decorator, WITHOUT parentheses.
+        # Dispatch async/sync decorator, depening on decorated function.
+        # - Only stage when using global decorator `@respx.mock`
+        # - Second stage when using local decorator `@respx.mock(...)`
         return async_decorator if inspect.iscoroutinefunction(func) else sync_decorator
 
     def __enter__(self) -> "HTTPXMock":
         self.start()
         return self
 
+    def __exit__(self, *args: typing.Any) -> None:
+        self.stop()
+
     async def __aenter__(self) -> "HTTPXMock":
         return self.__enter__()
 
-    def __exit__(self, *args: typing.Any) -> None:
-        try:
-            if self._assert_all_called:
-                self.assert_all_called()
-        finally:
-            self.stop()
-
     async def __aexit__(self, *args: typing.Any) -> None:
         self.__exit__(*args)
 
     def start(self) -> None:
         """
-        Starts mocking httpx.
+        Register mock/patterns and starts patching HTTPX.
+        """
+        self._register(self)
+
+    def stop(self, clear: bool = True, reset: bool = True) -> None:
+        """
+        Unregister mock/patterns and stop patching HTTPX, when no registered mocks left.
         """
+        try:
+            if self._assert_all_called:
+                self.assert_all_called()
+        finally:
+            if clear:
+                self.clear()
+            if reset:
+                self.reset()
+
+            self._unregister(self)
+
+    def _register(self, httpx_mock: "HTTPXMock") -> None:
+        # Ensure we patch HTTPX using proxy instance
+        if self._proxy:
+            self._proxy._register(httpx_mock)
+            return
+
+        # Register given mock instance / patterns
+        self._mocks.append(httpx_mock)
+        self._patch()
+
+    def _unregister(self, httpx_mock: "HTTPXMock") -> None:
+        # Ensure we unpatch HTTPX using proxy instance
+        if self._proxy is not None:
+            self._proxy._unregister(httpx_mock)
+            return
+
+        # Unregister given mock instance / patterns
+        assert httpx_mock in self._mocks, "HTTPX mock already stopped!"
+        self._mocks.remove(httpx_mock)
+        self._unpatch()
+
+    def _patch(self) -> None:
+        # Ensure we only patch HTTPX once!
+        if self._patchers:
+            return
+
         # Unbound -> bound spy version of Client.send
         def unbound_sync_send(
             client: Client, request: Request, **kwargs: typing.Any
@@ -117,6 +161,7 @@ class HTTPXMock:
         ) -> Response:
             return await self.__AsyncClient__send__spy(client, request, **kwargs)
 
+        # Start patching HTTPX
         mockers = (
             ("httpx.Client.send", unbound_sync_send),
             ("httpx.AsyncClient.send", unbound_async_send),
@@ -126,18 +171,16 @@ class HTTPXMock:
             patcher.start()
             self._patchers.append(patcher)
 
-    def stop(self, reset: bool = True) -> None:
-        """
-        Stops mocking httpx.
-        """
+    def _unpatch(self) -> None:
+        # Ensure we don't stop patching HTTPX when registered mocks exists
+        if self._mocks:
+            return
+
+        # Stop patching HTTPX
         while self._patchers:
             patcher = self._patchers.pop()
             patcher.stop()
 
-        if reset:
-            self.clear()
-            self.reset()
-
     def clear(self) -> None:
         """
         Clears added patterns and aliases.
@@ -208,50 +251,59 @@ class HTTPXMock:
     def _match(
         self, request: Request
     ) -> typing.Tuple[
-        typing.Optional[RequestPattern], typing.Optional[ResponseTemplate]
+        "HTTPXMock", typing.Optional[RequestPattern], typing.Optional[ResponseTemplate]
     ]:
         matched_pattern: typing.Optional[RequestPattern] = None
         matched_pattern_index: typing.Optional[int] = None
         response: typing.Optional[ResponseTemplate] = None
-
-        for i, pattern in enumerate(self._patterns):
-            match = pattern.match(request)
-            if not match:
-                continue
-
-            if matched_pattern_index is not None:
-                # Multiple matches found, drop and use the first one
-                self._patterns.pop(matched_pattern_index)
+        # if request.url == "https://foo.bar/asgi/":
+        # import pdb; pdb.set_trace()
+
+        # Iterate all started mockers and their patterns
+        for httpx_mock in self._mocks:
+            patterns = httpx_mock._patterns
+
+            for i, pattern in enumerate(patterns):
+                match = pattern.match(request)
+                if not match:
+                    continue
+
+                if matched_pattern_index is not None:
+                    # Multiple matches found, drop and use the first one
+                    patterns.pop(matched_pattern_index)
+                    break
+
+                used_mock = httpx_mock
+                matched_pattern = pattern
+                matched_pattern_index = i
+
+                if isinstance(match, ResponseTemplate):
+                    # Mock response
+                    response = match
+                elif isinstance(match, Request):
+                    # Pass-through request
+                    response = None
+                else:
+                    raise ValueError(
+                        (
+                            "Matched request pattern must return either a "
+                            'ResponseTemplate or a Request, got "{}"'
+                        ).format(type(match))
+                    )
+
+            if matched_pattern:
                 break
 
-            matched_pattern = pattern
-            matched_pattern_index = i
-
-            if isinstance(match, ResponseTemplate):
-                # Mock response
-                response = match
-            elif isinstance(match, Request):
-                # Pass-through request
-                response = None
-            else:
-                raise ValueError(
-                    (
-                        "Matched request pattern must return either a "
-                        'ResponseTemplate or a Request, got "{}"'
-                    ).format(type(match))
-                )
-
-        # Assert we always get a pattern match, if check is enabled
-        assert (
-            not self._assert_all_mocked
-            or self._assert_all_mocked
-            and matched_pattern is not None
-        ), f"RESPX: {request!r} not mocked!"
-
         if matched_pattern is None:
+            # Assert we always get a pattern match, if check is enabled
+            allows_unmocked = tuple(m for m in self._mocks if not m._assert_all_mocked)
+            assert allows_unmocked, f"RESPX: {request!r} not mocked!"
+
+            # Relate default response to first mocker that allows unmocked requests
+            used_mock = allows_unmocked[0]
             response = ResponseTemplate()
 
-        return matched_pattern, response
+        return used_mock, matched_pattern, response
 
     def _capture(
         self,
@@ -279,7 +331,7 @@ class HTTPXMock:
         patchers = []
 
         # 1. Match request against added patterns
-        pattern, response = self._match(request)
+        httpx_mock, pattern, response = self._match(request)
 
         if response is not None:
             # 2. Patch request url with response for later pickup in patched dispatcher
@@ -305,7 +357,7 @@ class HTTPXMock:
                 patchers.append(patcher)
 
         try:
-            yield partial(self._capture, pattern=pattern)
+            yield partial(httpx_mock._capture, pattern=pattern)
         finally:
             # 4. Stop patching
             for patcher in patchers:
diff --git a/respx/fixtures.py b/respx/fixtures.py
new file mode 100644
index 0000000..1513fbf
--- /dev/null
+++ b/respx/fixtures.py
@@ -0,0 +1,12 @@
+try:
+    import pytest
+except ImportError:  # pragma: nocover
+    pass
+else:
+    import asyncio
+
+    @pytest.fixture(scope="session")
+    def session_event_loop():
+        loop = asyncio.get_event_loop_policy().new_event_loop()
+        yield loop
+        loop.close()
diff --git a/setup.cfg b/setup.cfg
index 03a432c..cdc5c7e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -47,5 +47,8 @@ warn_redundant_casts = True
 warn_unused_ignores = True
 warn_unreachable = True
 
+[mypy-pytest.*]
+ignore_missing_imports = True
+
 [mypy-asynctest.*]
 ignore_missing_imports = True
diff --git a/setup.py b/setup.py
index eca8d91..f0cb20d 100644
--- a/setup.py
+++ b/setup.py
@@ -32,5 +32,5 @@ setup(
     include_package_data=True,
     zip_safe=False,
     python_requires=">=3.6",
-    install_requires=["httpx==0.11.0", "asynctest"],
+    install_requires=["httpx>=0.11,<0.12", "asynctest"],
 )


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b95489e..1d474a0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,24 +1,33 @@
-import asyncio
-
 import httpx
 import pytest
 
 import respx
+from respx.fixtures import session_event_loop as event_loop  # noqa
+
+
+@pytest.fixture
+async def client():
+    async with httpx.AsyncClient() as client:
+        yield client
 
 
-@pytest.yield_fixture
+@pytest.fixture
 async def httpx_mock():
-    async with respx.HTTPXMock() as httpx_mock:
+    async with respx.mock(base_url="https://httpx.mock") as httpx_mock:
+        httpx_mock.get("/", status_code=404, alias="index")
         yield httpx_mock
 
 
-@pytest.fixture
-def future():
-    loop = asyncio.get_event_loop()
-    return loop.run_until_complete
+@pytest.fixture(scope="session")
+async def mocked_foo():
+    async with respx.mock(base_url="https://foo.api") as httpx_mock:
+        httpx_mock.get("/", status_code=202, alias="index")
+        httpx_mock.get("/bar/", alias="bar")
+        yield httpx_mock
 
 
-@pytest.fixture
-async def client():
-    async with httpx.AsyncClient() as client:
-        yield client
+@pytest.fixture(scope="session")
+async def mocked_ham():
+    async with respx.mock(base_url="https://ham.api") as httpx_mock:
+        httpx_mock.get("/", status_code=200, alias="index")
+        yield httpx_mock
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 1f3ff54..c80e135 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -19,17 +19,47 @@ async def test_decorating_test(client):
 
 
 @pytest.mark.asyncio
-async def test_mock_fixture(client, httpx_mock):
+async def test_mock_request_fixture(client, httpx_mock):
     assert respx.stats.call_count == 0
     assert httpx_mock.stats.call_count == 0
-    request = httpx_mock.get("https://foo.bar/", status_code=202)
-    response = await client.get("https://foo.bar/")
+    response = await client.get("https://httpx.mock/")
+    request = httpx_mock.aliases["index"]
     assert request.called is True
-    assert response.status_code == 202
+    assert response.is_error
+    assert response.status_code == 404
     assert respx.stats.call_count == 0
     assert httpx_mock.stats.call_count == 1
 
 
+@pytest.mark.asyncio
+async def test_mock_single_session_fixture(client, mocked_foo):
+    current_foo_call_count = mocked_foo.stats.call_count
+    response = await client.get("https://foo.api/bar/")
+    request = mocked_foo.aliases["bar"]
+    assert request.called is True
+    assert response.status_code == 200
+    assert mocked_foo.stats.call_count == current_foo_call_count + 1
+
+
+@pytest.mark.asyncio
+async def test_mock_multiple_session_fixtures(client, mocked_foo, mocked_ham):
+    current_foo_call_count = mocked_foo.stats.call_count
+    current_ham_call_count = mocked_ham.stats.call_count
+
+    response = await client.get("https://foo.api/")
+    request = mocked_foo.aliases["index"]
+    assert request.called is True
+    assert response.status_code == 202
+
+    response = await client.get("https://ham.api/")
+    request = mocked_foo.aliases["index"]
+    assert request.called is True
+    assert response.status_code == 200
+
+    assert mocked_foo.stats.call_count == current_foo_call_count + 1
+    assert mocked_ham.stats.call_count == current_ham_call_count + 1
+
+
 def test_global_sync_decorator():
     @respx.mock
     def test():
@@ -40,12 +70,13 @@ def test_global_sync_decorator():
         assert response.status_code == 202
         assert respx.stats.call_count == 1
 
+    assert respx.stats.call_count == 0
     test()
     assert respx.stats.call_count == 0
 
 
 @pytest.mark.asyncio
-async def test_global_async_decorator(client, future):
+async def test_global_async_decorator(client):
     @respx.mock
     async def test():
         assert respx.stats.call_count == 0
@@ -55,6 +86,7 @@ async def test_global_async_decorator(client, future):
         assert response.status_code == 202
         assert respx.stats.call_count == 1
 
+    assert respx.stats.call_count == 0
     await test()
     assert respx.stats.call_count == 0
 
@@ -70,6 +102,7 @@ def test_local_sync_decorator():
         assert respx.stats.call_count == 0
         assert httpx_mock.stats.call_count == 1
 
+    assert respx.stats.call_count == 0
     test()
     assert respx.stats.call_count == 0
 
@@ -134,16 +167,37 @@ async def test_local_contextmanager(client):
     assert respx.stats.call_count == 0
 
 
-@pytest.mark.xfail
 @pytest.mark.asyncio
-async def test_nested_contextmanager(client):
-    """
+async def test_nested_local_contextmanager(client):
     with respx.mock() as httpx_mock_1:
         get_request = httpx_mock_1.get("https://foo/bar/", status_code=202)
 
         with respx.mock() as httpx_mock_2:
             post_request = httpx_mock_2.post("https://foo/bar/", status_code=201)
 
+            response = await client.get("https://foo/bar/")
+            assert get_request.called is True
+            assert response.status_code == 202
+            assert respx.stats.call_count == 0
+            assert httpx_mock_1.stats.call_count == 1
+            assert httpx_mock_2.stats.call_count == 0
+
+            response = await client.post("https://foo/bar/")
+            assert post_request.called is True
+            assert response.status_code == 201
+            assert respx.stats.call_count == 0
+            assert httpx_mock_1.stats.call_count == 1
+            assert httpx_mock_2.stats.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_nested_global_contextmanager(client):
+    with respx.mock:
+        get_request = respx.get("https://foo/bar/", status_code=202)
+
+        with respx.mock:
+            post_request = respx.post("https://foo/bar/", status_code=201)
+
             response = await client.get("https://foo/bar/")
             assert get_request.called is True
             assert response.status_code == 202
@@ -153,7 +207,6 @@ async def test_nested_contextmanager(client):
             assert post_request.called is True
             assert response.status_code == 201
             assert respx.stats.call_count == 2
-    """
 
 
 @pytest.mark.asyncio
@@ -216,12 +269,17 @@ async def test_start_stop(client):
         assert response.text == ""
         assert respx.stats.call_count == 1
 
-        respx.stop(reset=False)
+        respx.stop(clear=False, reset=False)
+        assert len(respx.mock._patterns) == 1
         assert respx.stats.call_count == 1
 
-        respx.stop()
+        respx.reset()
+        assert len(respx.mock._patterns) == 1
         assert respx.stats.call_count == 0
 
+        respx.clear()
+        assert len(respx.mock._patterns) == 0
+
     except Exception:  # pragma: nocover
         respx.stop()  # Cleanup global state on error, to not affect other tests
         raise