Repository: lundberg/respx
Pull request #28 (2019-11-24 11:33:13)

Configurable decorator and context manager
Allows the `respx.mock` decorator and context manager to configure the mock.

```py
@respx.mock(assert_all_mocked=True)
def test_something(httpx_mock):
    httpx_mock.get(...)
    httpx.get(...)

with respx.mock(assert_all_called=False) as httpx_mock:
    httpx_mock.get(...)
    httpx.get(...)
```

### Note
This PR **breaks** any previous usage of the context manager `with respx.mock():`, *with parentheses*, together with the global `respx.stats`.


Patch:
diff --git a/README.md b/README.md
index 613dd46..2b41e42 100644
--- a/README.md
+++ b/README.md
@@ -25,34 +25,33 @@ def test_something():
     response = httpx.post("https://foo.bar/baz/")
     assert request.called
     assert response.status_code == 201
+
+
+@respx.mock(assert_all_mocked=False)
+def test_something(httpx_mock):
+    response = httpx.post("https://foo.bar/baz/")
+    assert response.status_code == 200
+
 ```
 
 
 ## Context Manager
 
-Using the high-level api:
-
 ```py
 import httpx
 import respx
 
 
-with respx.mock():
+with respx.mock:
     request = respx.get("https://foo.bar/", content={"foo": "bar"})
     response = httpx.get("https://foo.bar/")
     assert request.called
     assert response.json() == {"foo": "bar"}
-```
-
-Using the low-level api:
-
-```py
-import httpx
-import respx
 
 
-with respx.HTTPXMock() as respx_mock:
-    request = respx_mock.get("https://foo.bar/", content={"foo": "bar"})
+with respx.mock(assert_all_called=False) as httpx_mock:
+    httpx_mock.get("https://ham.spam/")
+    request = httpx_mock.get("https://foo.bar/", content={"foo": "bar"})
     response = httpx.get("https://foo.bar/")
     assert request.called
     assert response.json() == {"foo": "bar"}
diff --git a/respx/__init__.py b/respx/__init__.py
index b36fa90..9cc3930 100644
--- a/respx/__init__.py
+++ b/respx/__init__.py
@@ -1,10 +1,10 @@
 # type: ignore
 from .mock import HTTPXMock
 
-__all__ = ["HTTPXMock"]
-
 # Expose mock api
-__httpx_mock = HTTPXMock(assert_all_called=False)
-__api_methods = list(filter(lambda m: not m.startswith("_"), dir(__httpx_mock)))
-__all__.extend(__api_methods)
-globals().update({method: getattr(__httpx_mock, method) for method in __api_methods})
+mock = HTTPXMock(assert_all_called=False, local=False)
+__api__ = list(filter(lambda m: not m.startswith("_"), dir(mock)))
+
+__all__ = ["HTTPXMock", "mock"]
+__all__.extend(__api__)
+globals().update({method: getattr(mock, method) for method in __api__})
diff --git a/respx/mock.py b/respx/mock.py
index 0305717..7c93664 100644
--- a/respx/mock.py
+++ b/respx/mock.py
@@ -36,8 +36,12 @@ __all__ = ["HTTPXMock"]
 
 class HTTPXMock:
     def __init__(
-        self, assert_all_called: bool = True, assert_all_mocked: bool = True
+        self,
+        assert_all_called: bool = True,
+        assert_all_mocked: bool = True,
+        local: bool = True,
     ) -> None:
+        self._is_local = local
         self._assert_all_called = assert_all_called
         self._assert_all_mocked = assert_all_mocked
         self._patchers: typing.List[asynctest.mock._patch] = []
@@ -48,46 +52,67 @@ class HTTPXMock:
             typing.Tuple[AsyncRequest, typing.Optional[BaseResponse]]
         ] = []
 
-    def __enter__(self) -> "HTTPXMock":
-        self.start()
-        return self
-
-    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 mock(self, func=None):
+    def __call__(
+        self,
+        func: typing.Optional[typing.Callable] = None,
+        assert_all_called: typing.Optional[bool] = None,
+        assert_all_mocked: typing.Optional[bool] = None,
+    ):
         """
-        Starts mocking and stops once wrapped function, or context, is executed.
+        Decorator or Context Manager.
+
+        Use decorator/manager with parantheses for local state, or without parantheses
+        for global state, i.e. shared patterns added outside of scope.
         """
-        # Context Manager
         if func is None:
-            return self
+            # A. First stage of "local" decorator, WITH parentheses.
+            # B. Only stage of "local" context manager, WITH parantheses,
+            #    "global" context maanager hits __enter__ directly.
+            settings = {}
+            if assert_all_called is not None:
+                settings["assert_all_called"] = assert_all_called
+            if assert_all_mocked is not None:
+                settings["assert_all_mocked"] = assert_all_mocked
+            return self.__class__(**settings)
 
         # Async Decorator
         @wraps(func)
         async def async_decorator(*args, **kwargs):
+            if self._is_local:
+                kwargs["httpx_mock"] = self
             async with self:
                 return await func(*args, **kwargs)
 
         # Sync Decorator
         @wraps(func)
         def sync_decorator(*args, **kwargs):
+            if self._is_local:
+                kwargs["httpx_mock"] = self
             with self:
                 return func(*args, **kwargs)
 
-        # Dispatch depening on decorated function
+        # Dispatch async/sync decorator, depening on decorated function
+        # A. Second stage of "local" decorator, WITH parantheses.
+        # A. Only stage of "global" decorator, WITHOUT parantheses.
         return async_decorator if inspect.iscoroutinefunction(func) else sync_decorator
 
+    def __enter__(self) -> "HTTPXMock":
+        self.start()
+        return self
+
+    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.
diff --git a/setup.cfg b/setup.cfg
index 98c0691..d5c5c64 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,7 +15,7 @@ force_grid_wrap = 0
 not_skip = __init__.py
 
 [coverage:run]
-source = respx
+source = respx,tests
 branch = True
 
 [coverage:report]


Test patch:
diff --git a/tests/test_mock.py b/tests/test_mock.py
index d510cf1..77c2cdc 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -11,66 +11,152 @@ import respx
 
 
 class HTTPXMockTestCase(asynctest.TestCase):
-    def test_api(self):
-        url = "https://foo/bar/"
-        request = respx.request("GET", url, status_code=202)
-
+    @respx.mock
+    def test_sync_global_decorator(self):
         self.assertEqual(respx.stats.call_count, 0)
 
-        respx.start()
-        response = httpx.get(url)
+        request = respx.get("https://foo/bar/", status_code=202)
+        response = httpx.get("https://foo/bar/")
+
         self.assertTrue(request.called)
         self.assertEqual(response.status_code, 202)
-        self.assertEqual(response.text, "")
         self.assertEqual(respx.stats.call_count, 1)
 
-        respx.stop(reset=False)
+    @respx.mock
+    async def test_async_global_decorator(self):
+        self.assertEqual(respx.stats.call_count, 0)
+
+        request = respx.get("https://foo/bar/", status_code=202)
+        async with httpx.AsyncClient() as client:
+            response = await client.get("https://foo/bar/")
+
+        self.assertTrue(request.called)
+        self.assertEqual(response.status_code, 202)
         self.assertEqual(respx.stats.call_count, 1)
 
-        respx.stop()
+    @respx.mock()
+    def test_sync_local_decorator(self, httpx_mock):
         self.assertEqual(respx.stats.call_count, 0)
+        self.assertEqual(httpx_mock.stats.call_count, 0)
 
-    @respx.mock
-    def test_mock_decorator(self):
-        respx.get("https://foo/bar/", status_code=202)
+        request = httpx_mock.get("https://foo/bar/", status_code=202)
         response = httpx.get("https://foo/bar/")
+
+        self.assertTrue(request.called)
         self.assertEqual(response.status_code, 202)
+        self.assertEqual(respx.stats.call_count, 0)
+        self.assertEqual(httpx_mock.stats.call_count, 1)
+
+    @respx.mock()
+    async def test_async_local_decorator(self, httpx_mock):
+        self.assertEqual(respx.stats.call_count, 0)
+        self.assertEqual(httpx_mock.stats.call_count, 0)
 
-    @respx.mock
-    async def test_mock_decorator_async(self):
-        respx.get("https://foo/bar/", status_code=202)
         async with httpx.AsyncClient() as client:
+            request = httpx_mock.get("https://foo/bar/", status_code=202)
             response = await client.get("https://foo/bar/")
-        self.assertEqual(response.status_code, 202)
 
-    def foo(self):
-        pass
+        self.assertTrue(request.called)
+        self.assertEqual(response.status_code, 202)
+        self.assertEqual(respx.stats.call_count, 0)
+        self.assertEqual(httpx_mock.stats.call_count, 1)
 
-    def test_mock_contextmanager(self):
+    def test_sync_global_contextmanager(self):
         self.assertEqual(respx.stats.call_count, 0)
 
-        with respx.mock():
-            respx.get("https://foo/bar/", status_code=202)
+        with respx.mock:
+            request = respx.get("https://foo/bar/", status_code=202)
             response = httpx.get("https://foo/bar/")
 
+            self.assertTrue(request.called)
             self.assertEqual(response.status_code, 202)
             self.assertEqual(respx.stats.call_count, 1)
 
         self.assertEqual(respx.stats.call_count, 0)
 
-    async def test_mock_contextmanager_async(self):
+    async def test_async_global_contextmanager(self):
         self.assertEqual(respx.stats.call_count, 0)
 
-        async with respx.mock():
-            respx.get("https://foo/bar/", status_code=202)
+        async with respx.mock:
             async with httpx.AsyncClient() as client:
+                request = respx.get("https://foo/bar/", status_code=202)
                 response = await client.get("https://foo/bar/")
 
+            self.assertTrue(request.called)
             self.assertEqual(response.status_code, 202)
             self.assertEqual(respx.stats.call_count, 1)
 
         self.assertEqual(respx.stats.call_count, 0)
 
+    def test_sync_local_contextmanager(self):
+        with respx.mock() as httpx_mock:
+            self.assertEqual(respx.stats.call_count, 0)
+            self.assertEqual(httpx_mock.stats.call_count, 0)
+
+            request = httpx_mock.get("https://foo/bar/", status_code=202)
+            response = httpx.get("https://foo/bar/")
+
+            self.assertTrue(request.called)
+            self.assertEqual(response.status_code, 202)
+            self.assertEqual(respx.stats.call_count, 0)
+            self.assertEqual(httpx_mock.stats.call_count, 1)
+
+        self.assertEqual(httpx_mock.stats.call_count, 0)
+
+    async def test_async_local_contextmanager(self):
+        async with respx.mock() as httpx_mock:
+            self.assertEqual(respx.stats.call_count, 0)
+            self.assertEqual(httpx_mock.stats.call_count, 0)
+
+            async with httpx.AsyncClient() as client:
+                request = httpx_mock.get("https://foo/bar/", status_code=202)
+                response = await client.get("https://foo/bar/")
+
+            self.assertTrue(request.called)
+            self.assertEqual(response.status_code, 202)
+            self.assertEqual(respx.stats.call_count, 0)
+            self.assertEqual(httpx_mock.stats.call_count, 1)
+
+        self.assertEqual(httpx_mock.stats.call_count, 0)
+
+    @respx.mock(assert_all_called=False, assert_all_mocked=False)
+    def test_decorator_with_settings(self, httpx_mock):
+        request = httpx_mock.get("https://ham/spam/", status_code=202)
+        response = httpx.get("https://foo/bar/")
+
+        self.assertFalse(request.called)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(httpx_mock.stats.call_count, 1)
+
+    def test_contextmanager_with_settings(self):
+        pass
+
+    def test_start_stop(self):
+        url = "https://foo/bar/"
+        request = respx.request("GET", url, status_code=202)
+
+        self.assertEqual(respx.stats.call_count, 0)
+
+        try:
+            respx.start()
+            response = httpx.get(url)
+
+            self.assertTrue(request.called)
+            self.assertEqual(response.status_code, 202)
+            self.assertEqual(response.text, "")
+            self.assertEqual(respx.stats.call_count, 1)
+
+            respx.stop(reset=False)
+            self.assertEqual(respx.stats.call_count, 1)
+
+            respx.stop()
+            self.assertEqual(respx.stats.call_count, 0)
+
+        except Exception:  # pragma: nocover
+            # Cleanup global state on error, to not affect other tests
+            respx.stop()
+            raise
+
     def test_http_methods(self):
         with respx.HTTPXMock() as httpx_mock:
             url = "https://foo/bar/"
@@ -121,8 +207,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertEqual(response.text, "whatever")
 
     def test_invalid_url_pattern(self):
-        with respx.mock():
-            foobar = respx.get(["invalid"], content="whatever")
+        with respx.HTTPXMock(assert_all_called=False) as httpx_mock:
+            foobar = httpx_mock.get(["invalid"], content="whatever")
             with self.assertRaises(ValueError):
                 httpx.get("https://foo/bar/")
 
@@ -279,7 +365,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertEqual(response.text, "foobar")
 
     async def test_async_client(self):
-        with respx.HTTPXMock() as httpx_mock:
+        async with respx.HTTPXMock() as httpx_mock:
             url = "https://foo/bar/"
             foobar = httpx_mock.get(url, content="foobar")
             async with httpx.AsyncClient() as client:
@@ -293,8 +379,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
         not hasattr(ConcurrencyBackend, "open_uds_stream"),
         "not yet implemented in httpx",
     )
-    async def test_uds(self):
-        with respx.HTTPXMock() as httpx_mock:
+    async def test_uds(self):  # pragma: nocover
+        async with respx.HTTPXMock() as httpx_mock:
             url = "https://foo/bar/"
             foobar = httpx_mock.get(url, content="foobar")
             async with httpx.AsyncClient(uds="/var/run/foobar.sock") as client:
@@ -305,12 +391,13 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertEqual(response.text, "foobar")
 
     def test_alias(self):
-        with respx.mock() as m:
+        with respx.HTTPXMock(assert_all_called=False) as httpx_mock:
             url = "https://foo/bar/"
-            foobar = respx.get(url, alias="foobar")
-            self.assertIn("foobar", respx.aliases)
-            self.assertEqual(respx.aliases["foobar"].url, foobar.url)
-            self.assertEqual(m["foobar"].url, foobar.url)
+            foobar = httpx_mock.get(url, alias="foobar")
+            self.assertNotIn("foobar", respx.aliases)
+            self.assertIn("foobar", httpx_mock.aliases)
+            self.assertEqual(httpx_mock.aliases["foobar"].url, foobar.url)
+            self.assertEqual(httpx_mock["foobar"].url, foobar.url)
 
     def test_exception(self):
         with respx.HTTPXMock() as httpx_mock:
@@ -407,8 +494,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(len(httpx_mock.calls), 1)
 
     def test_pass_through_with_arg(self):
-        with respx.mock():
-            request = respx.get("https://www.example.org/", pass_through=True)
+        with respx.HTTPXMock() as httpx_mock:
+            request = httpx_mock.get("https://www.example.org/", pass_through=True)
 
             with asynctest.mock.patch(
                 "asyncio.open_connection",
@@ -422,8 +509,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertTrue(request.pass_through)
 
     def test_pass_through_with_custom_matcher(self):
-        with respx.mock():
-            request = respx.request(lambda request, response: request)
+        with respx.HTTPXMock() as httpx_mock:
+            request = httpx_mock.request(lambda request, response: request)
 
             with asynctest.mock.patch(
                 "asyncio.open_connection",
@@ -493,10 +580,10 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertEqual(alias.alias, foobar2.alias)
 
     def test_sync_stats(self, backend=None):
-        with respx.mock():
+        with respx.HTTPXMock() as httpx_mock:
             url = "https://foo/bar/1/"
-            foobar1 = respx.get(url, status_code=202, alias="get_foobar")
-            foobar2 = respx.delete(url, status_code=200, alias="del_foobar")
+            foobar1 = httpx_mock.get(url, status_code=202, alias="get_foobar")
+            foobar2 = httpx_mock.delete(url, status_code=200, alias="del_foobar")
 
             with httpx.Client(backend=backend) as client:
                 get_response = client.get(url)
@@ -506,9 +593,10 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertTrue(foobar2.called)
             self.assertEqual(len(foobar1.calls), 1)
             self.assertEqual(len(foobar2.calls), 1)
-            self.assertEqual(len(respx.calls), 2)
-            self.assertEqual(respx.calls[0], foobar1.calls[-1])
-            self.assertEqual(respx.calls[1], foobar2.calls[-1])
+            self.assertEqual(len(respx.calls), 0)
+            self.assertEqual(len(httpx_mock.calls), 2)
+            self.assertEqual(httpx_mock.calls[0], foobar1.calls[-1])
+            self.assertEqual(httpx_mock.calls[1], foobar2.calls[-1])
 
             _request, _response = foobar1.calls[-1]
             self.assertIsInstance(_request, httpx.AsyncRequest)
@@ -533,7 +621,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             await asyncio.sleep(0.2 if page == "one" else 0.1)
             return page
 
-        url_pattern = re.compile(r"https://foo/(?P<page>[a-z]+)/$")
+        url_pattern = re.compile(r"https://foo/(?P<page>\w+)/$")
         respx.get(url_pattern, content=content)
 
         async with httpx.AsyncClient() as client: