Repository: lundberg/respx

Issue:
Raise on non-matching request
Add setting to raise an exception when a request is captured but not matching any added pattern, with the possibility to disable/enable the feature.

Decide what the default behaviour should be, raise or always mock responses.

What exception should be raised, `AssertionError` or `ConnectionError`?

**Idea**:
```py
with respx.HTTPXMock(assert_all_mocked=False):
    respx.get("https://foo/bar/")
    httpx.get("https://ham/spam/")  # Would raise an AssertionError("non-matching")
```

Discussion:

Patch:
diff --git a/respx/mock.py b/respx/mock.py
index c3f45e5..fd62371 100644
--- a/respx/mock.py
+++ b/respx/mock.py
@@ -24,8 +24,11 @@ __all__ = ["HTTPXMock"]
 
 
 class HTTPXMock:
-    def __init__(self, assert_all_called: bool = True) -> None:
+    def __init__(
+        self, assert_all_called: bool = True, assert_all_mocked: bool = True
+    ) -> None:
         self._assert_all_called = assert_all_called
+        self._assert_all_mocked = assert_all_mocked
         self._patchers: typing.List[asynctest.mock._patch] = []
         self._patterns: typing.List[RequestPattern] = []
         self.aliases: typing.Dict[str, RequestPattern] = {}
@@ -93,7 +96,7 @@ class HTTPXMock:
     def assert_all_called(self):
         assert all(
             (pattern.called for pattern in self._patterns)
-        ), "not all requests called"
+        ), "RESPX: some mocked requests were not called!"
 
     def add(self, pattern: RequestPattern, alias: typing.Optional[str] = None) -> None:
         self._patterns.append(pattern)
@@ -143,29 +146,29 @@ class HTTPXMock:
     ) -> typing.Tuple[
         typing.Optional[RequestPattern], typing.Optional[ResponseTemplate]
     ]:
-        found_index: typing.Optional[int] = None
         matched_pattern: typing.Optional[RequestPattern] = None
-        matched_response: typing.Optional[ResponseTemplate] = 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 found_index is not None:
+            if matched_pattern_index is not None:
                 # Multiple matches found, drop and use the first one
-                self._patterns.pop(found_index)
+                self._patterns.pop(matched_pattern_index)
                 break
 
-            found_index = i
             matched_pattern = pattern
+            matched_pattern_index = i
 
             if isinstance(match, ResponseTemplate):
                 # Mock response
-                matched_response = match
+                response = match
             elif isinstance(match, AsyncRequest):
                 # Pass-through request
-                matched_response = None
+                response = None
             else:
                 raise ValueError(
                     (
@@ -174,7 +177,17 @@ class HTTPXMock:
                     ).format(type(match))
                 )
 
-        return matched_pattern, matched_response
+        # 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:
+            response = ResponseTemplate()
+
+        return matched_pattern, response
 
     @contextmanager
     def _patch_backend(
@@ -226,15 +239,11 @@ class HTTPXMock:
         and mocks client backend open stream methods.
         """
         # 1. Match request against added patterns
-        pattern, template = self._match(request)
-
-        if pattern is None:
-            template = ResponseTemplate()
+        pattern, _response = self._match(request)
 
         # 2. Patch client's backend and continue to original _get_response
         try:
-            global _get_response
-            with self._patch_backend(client.concurrency_backend, request, template):
+            with self._patch_backend(client.concurrency_backend, request, _response):
                 response = None
                 response = await _get_response(client, request, **kwargs)
         finally:


Test patch:
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 9624c47..97fd913 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -106,9 +106,11 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertFalse(foobar.called)
 
     def test_unknown_url(self):
-        with respx.mock():
+        with respx.HTTPXMock(
+            assert_all_called=False, assert_all_mocked=False
+        ) as httpx_mock:
             url = "https://foo/bar/"
-            foobar = respx.post(url)  # Non-matching method
+            foobar = httpx_mock.post(url)  # Non-matching method
             response = httpx.get(url)
 
             self.assertFalse(foobar.called)
@@ -118,8 +120,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
             )
             self.assertEqual(response.text, "")
 
-            self.assertEqual(len(respx.calls), 1)
-            request, response = respx.calls[-1]
+            self.assertEqual(len(httpx_mock.calls), 1)
+            request, response = httpx_mock.calls[-1]
             self.assertIsNotNone(request)
             self.assertIsNotNone(response)
 
@@ -294,6 +296,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
                 side_effect=ValueError("mock"),
             ):
                 url = "https://foo/bar/1/"
+                httpx_mock.get(url)
                 with self.assertRaises(ValueError):
                     httpx.get(url)
 
@@ -310,7 +313,9 @@ class HTTPXMockTestCase(asynctest.TestCase):
                 return response
 
         with respx.HTTPXMock(assert_all_called=False) as httpx_mock:
-            request = httpx_mock.request(matcher, status_code=202, headers={"X-Ham": "Spam"})
+            request = httpx_mock.request(
+                matcher, status_code=202, headers={"X-Ham": "Spam"}
+            )
             response = httpx.get("https://foo/bar/")
 
             self.assertEqual(response.status_code, 202)
@@ -361,6 +366,20 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertTrue(request1.called)
             self.assertTrue(request2.called)
 
+    def test_assert_all_mocked_fail(self):
+        with self.assertRaises(AssertionError):
+            with respx.HTTPXMock(assert_all_mocked=True) as httpx_mock:
+                httpx.get("https://foo/bar/")
+
+        self.assertEqual(len(httpx_mock.calls), 0)
+
+    def test_assert_all_mocked_disabled(self):
+        with respx.HTTPXMock(assert_all_mocked=False) as httpx_mock:
+            response = httpx.get("https://foo/bar/")
+
+            self.assertEqual(response.status_code, 200)
+            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)