Repository: lundberg/respx
Pull request #25 (2019-11-21 10:08:54)

Add more stats
Adds `respx.stats`, `request_pattern.stats` and `request_pattern.call_count`.

codecov-io: # [Codecov](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=h1) Report
> Merging [#25](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=desc) into [master](https://codecov.io/gh/lundberg/respx/commit/7c85678a72187b97968e4320b38e8fbaf67a6896?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/lundberg/respx/pull/25/graphs/tree.svg?width=650&token=wJJ1eMBSK2&height=150&src=pr)](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master    #25   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files           2      2           
  Lines         253    256    +3     
  Branches       37     37           
=====================================
+ Hits          253    256    +3
```


| [Impacted Files](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [respx/models.py](https://codecov.io/gh/lundberg/respx/pull/25/diff?src=pr&el=tree#diff-cmVzcHgvbW9kZWxzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [respx/mock.py](https://codecov.io/gh/lundberg/respx/pull/25/diff?src=pr&el=tree#diff-cmVzcHgvbW9jay5weQ==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=footer). Last update [7c85678...880f6d9](https://codecov.io/gh/lundberg/respx/pull/25?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index ee20b7f..613dd46 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ import respx
 with respx.HTTPXMock() as respx_mock:
     request = respx_mock.get("https://foo.bar/", content={"foo": "bar"})
     response = httpx.get("https://foo.bar/")
-    assert len(respx_mock.calls) == 1
+    assert request.called
     assert response.json() == {"foo": "bar"}
 ```
 
@@ -67,7 +67,7 @@ To mock a response, define the *request pattern* to match and the *response deta
 
 For regular and simple use, use the HTTP method shorthands:
 
-**`respx.get`**(*url=None, status_code=None, content=None, content_type=None, headers=None, pass_through=False, alias=None*)
+**`respx.get`**(*url=None, status_code=None, content=None, content_type=None, headers=None, pass_through=False, alias=None*) -> RequestPattern
 
 **`respx.options`**(*url=None, ...*)
 
@@ -83,13 +83,13 @@ For regular and simple use, use the HTTP method shorthands:
 
 For advanced use:
 
-**`respx.request`**(*method, url=None, status_code=None, content=None, content_type=None, headers=None, pass_through=False, alias=None*)
+**`respx.request`**(*method, url=None, status_code=None, content=None, content_type=None, headers=None, pass_through=False, alias=None*) -> RequestPattern
 
 ### Parameters
 
 > * **method** - *str | callable*  
 >   * Request HTTP method to match - GET, OPTIONS, HEAD, POST, PUT, PATCH or DELETE.
->   * Request match callback. *See [Custom request matcher](#custom-request-matcher).*
+>   * Request match callback. *See [Custom request matching](#custom-request-matching).*
 > * **url** - *(optional) str | pattern*  
 >   * Request URL exact string to match.
 >   * Request URL pattern to match. *See [URL pattern matching](#url-pattern-matching).*
@@ -185,7 +185,7 @@ def test_something():
 ```
 
 
-## Custom request matcher
+## Custom request matching
 
 For full control of what to *match* and what response to *mock*, pass a callback function as the `method` parameter.
 
@@ -228,7 +228,7 @@ def test_something():
     
     response = httpx.post("https://foo.bar/baz/", headers={"X-Auth-Token": "token"})
     assert response.status_code == 201
-    assert len(custom_request.calls) == 2
+    assert custom_request.call_count == 2
 ```
 
 
@@ -273,16 +273,17 @@ When using the low-level `respx.HTTPXMock` context manager, both checks is **ena
 
 ```py
 with respx.HTTPXMock(assert_all_called=False, assert_all_mocked=False) as respx_mock:
-    response = httpx.get("https://foo.bar/")  # Will not raise AssersionError, and instead auto mock.
+    response = httpx.get("https://foo.bar/")  # Will not raise AssersionError, but instead auto mock.
     assert response.status_code == 200
-    assert len(respx_mock.calls) == 1
+    assert respx_mock.stats.call_count == 1
 ```
 
 
 ## Call stats
 
-The global `respx` api has a `.calls` list, containing captured (`requests`, `responses`) tuples.  
-Each mocked request pattern has its own `.calls` list and `.called` check.
+The global `respx` api has a `.calls` list, containing captured (`request`, `response`) tuples. On top of that there's also a *MagicMock* `.stats` object with all its bells and whistles, i.e. `call_count`, `assert_called_once` etc.
+
+Request patterns has their own `.calls` and `.stats`, along with shortcuts to stats`.called` and `.call_count`.
 
 ```py
 import httpx
@@ -298,17 +299,17 @@ def test_something():
     httpx.get("https://foo.bar/")
     index_request = respx.aliases["index"]  # Alias
     assert index_request.called
-    assert len(index_request.calls) == 1
+    assert index_request.call_count == 1
 
     httpx.post("https://foo.bar/baz/")
     assert create_request.called
-    assert len(create_request.calls) == 1
+    assert create_request.call_count == 1
 
     httpx.put("https://foo.bar/baz/123/")
     assert put_request.called
-    assert len(put_request.calls) == 1
+    assert put_request.call_count == 1
     
-    assert len(respx.calls) == 3
+    assert respx.stats.call_count == 3
     
     request, response = respx.calls[-1]
     assert request.method == "PUT"
diff --git a/respx/__init__.py b/respx/__init__.py
index c084937..b36fa90 100644
--- a/respx/__init__.py
+++ b/respx/__init__.py
@@ -1,3 +1,4 @@
+# type: ignore
 from .mock import HTTPXMock
 
 __all__ = ["HTTPXMock"]
diff --git a/respx/mock.py b/respx/mock.py
index 4daa0f3..5f71368 100644
--- a/respx/mock.py
+++ b/respx/mock.py
@@ -42,25 +42,21 @@ class HTTPXMock:
         self._patchers: typing.List[asynctest.mock._patch] = []
         self._patterns: typing.List[RequestPattern] = []
         self.aliases: typing.Dict[str, RequestPattern] = {}
+        self.stats = asynctest.mock.MagicMock()
         self.calls: typing.List[
             typing.Tuple[AsyncRequest, typing.Optional[BaseResponse]]
         ] = []
 
-    def clear(self):
-        self._patchers.clear()
-        self._patterns.clear()
-        self.aliases.clear()
-        self.calls.clear()
-
     def __enter__(self) -> "HTTPXMock":
         self.start()
         return self
 
     def __exit__(self, *args: typing.Any) -> None:
-        self.stop()
-        if self._assert_all_called:
-            self.assert_all_called()
-        self.clear()
+        try:
+            if self._assert_all_called:
+                self.assert_all_called()
+        finally:
+            self.stop()
 
     def mock(self, func=None):
         """
@@ -100,7 +96,7 @@ class HTTPXMock:
             patcher.start()
             self._patchers.append(patcher)
 
-    def stop(self) -> None:
+    def stop(self, reset: bool = True) -> None:
         """
         Stops mocking httpx.
         """
@@ -108,6 +104,16 @@ class HTTPXMock:
             patcher = self._patchers.pop()
             patcher.stop()
 
+        if reset:
+            self.reset()
+
+    def reset(self):
+        self._patchers.clear()
+        self._patterns.clear()
+        self.aliases.clear()
+        self.calls.clear()
+        self.stats.reset_mock()
+
     def assert_all_called(self):
         assert all(
             (pattern.called for pattern in self._patterns)
@@ -214,9 +220,14 @@ class HTTPXMock:
         Captures request and response calls for statistics.
         """
         if pattern:
-            pattern(request, response)
+            pattern.stats(request, response)
+
+        self.stats(request, response)
 
-        self.calls.append((request, response))
+        # Copy stats due to unwanted use of property refs in the high-level api
+        self.calls[:] = (
+            (request, response) for (request, response), _ in self.stats.call_args_list
+        )
 
     @contextmanager
     def _patch_backend(
diff --git a/respx/models.py b/respx/models.py
index df82e6a..c769e31 100644
--- a/respx/models.py
+++ b/respx/models.py
@@ -2,9 +2,9 @@ import json as jsonlib
 import re
 import typing
 from functools import partial
-from unittest import mock
 
-from httpx.models import URL, AsyncRequest, BaseResponse, Headers, HeaderTypes
+import asynctest
+from httpx.models import URL, AsyncRequest, Headers, HeaderTypes
 
 Regex = type(re.compile(""))
 Kwargs = typing.Dict[str, typing.Any]
@@ -35,7 +35,8 @@ class ResponseTemplate:
             headers.update(self._headers)
         return headers
 
-    def get_content(self) -> bytes:
+    @property
+    def content(self) -> bytes:
         content = self._content
 
         if isinstance(content, Exception):
@@ -57,11 +58,10 @@ class ResponseTemplate:
 
         return content
 
-    def set_content(self, content: ContentDataTypes) -> None:
+    @content.setter
+    def content(self, content: ContentDataTypes) -> None:
         self._content = content
 
-    content = property(get_content, set_content)
-
     def clone(self, context: typing.Optional[Kwargs] = None) -> "ResponseTemplate":
         return ResponseTemplate(
             self.status_code, self._headers, self._content, context=context
@@ -91,24 +91,22 @@ class RequestPattern:
 
         self.response = response
         self.alias = alias
-
-        self._stats = mock.MagicMock()
+        self.stats = asynctest.mock.MagicMock()
 
     @property
     def called(self):
-        return self._stats.called
+        return self.stats.called
+
+    @property
+    def call_count(self):
+        return self.stats.call_count
 
     @property
     def calls(self):
         return [
-            (request, response) for (request, response), _ in self._stats.call_args_list
+            (request, response) for (request, response), _ in self.stats.call_args_list
         ]
 
-    def __call__(
-        self, request: AsyncRequest, response: typing.Optional[BaseResponse]
-    ) -> None:
-        self._stats(request, response)
-
     def match(
         self, request: AsyncRequest
     ) -> typing.Optional[typing.Union[AsyncRequest, ResponseTemplate]]:


Test patch:
diff --git a/tests/test_mock.py b/tests/test_mock.py
index 7218de5..ba3b145 100644
--- a/tests/test_mock.py
+++ b/tests/test_mock.py
@@ -5,7 +5,6 @@ import httpx
 import trio
 from httpx.concurrency.base import ConcurrencyBackend
 from httpx.concurrency.trio import TrioBackend
-from httpx.exceptions import ConnectTimeout
 
 import respx
 
@@ -15,20 +14,20 @@ class HTTPXMockTestCase(asynctest.TestCase):
         url = "https://foo/bar/"
         request = respx.request("GET", url, status_code=202)
 
-        self.assertEqual(len(respx.calls), 0)
+        self.assertEqual(respx.stats.call_count, 0)
 
         respx.start()
         response = httpx.get(url)
         self.assertTrue(request.called)
         self.assertEqual(response.status_code, 202)
         self.assertEqual(response.text, "")
-        self.assertEqual(len(respx.calls), 1)
+        self.assertEqual(respx.stats.call_count, 1)
 
-        respx.stop()
-        self.assertEqual(len(respx.calls), 1)
+        respx.stop(reset=False)
+        self.assertEqual(respx.stats.call_count, 1)
 
-        respx.clear()
-        self.assertEqual(len(respx.calls), 0)
+        respx.stop()
+        self.assertEqual(respx.stats.call_count, 0)
 
     @respx.mock
     def test_mock_decorator(self):
@@ -37,16 +36,16 @@ class HTTPXMockTestCase(asynctest.TestCase):
         self.assertEqual(response.status_code, 202)
 
     def test_mock_contextmanager(self):
-        self.assertEqual(len(respx.calls), 0)
+        self.assertEqual(respx.stats.call_count, 0)
 
         with respx.mock():
             respx.get("https://foo/bar/", status_code=202)
             response = httpx.get("https://foo/bar/")
 
             self.assertEqual(response.status_code, 202)
-            self.assertEqual(len(respx.calls), 1)
+            self.assertEqual(respx.stats.call_count, 1)
 
-        self.assertEqual(len(respx.calls), 0)
+        self.assertEqual(respx.stats.call_count, 0)
 
     def test_http_methods(self):
         with respx.HTTPXMock() as httpx_mock:
@@ -75,7 +74,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(response.status_code, 501)
 
             self.assertTrue(m.called)
-            self.assertEqual(len(httpx_mock.calls), 7)
+            self.assertEqual(httpx_mock.stats.call_count, 7)
 
     def test_string_url_pattern(self):
         with respx.HTTPXMock() as httpx_mock:
@@ -120,7 +119,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             )
             self.assertEqual(response.text, "")
 
-            self.assertEqual(len(httpx_mock.calls), 1)
+            self.assertEqual(httpx_mock.stats.call_count, 1)
             request, response = httpx_mock.calls[-1]
             self.assertIsNotNone(request)
             self.assertIsNotNone(response)
@@ -137,15 +136,15 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(response1.status_code, 201)
             self.assertEqual(response2.status_code, 409)
             self.assertEqual(response3.status_code, 409)
-            self.assertEqual(len(httpx_mock.calls), 3)
+            self.assertEqual(httpx_mock.stats.call_count, 3)
 
             self.assertTrue(one.called)
-            self.assertTrue(len(one.calls), 1)
+            self.assertTrue(one.call_count, 1)
             statuses = [response.status_code for _, response in one.calls]
             self.assertListEqual(statuses, [201])
 
             self.assertTrue(two.called)
-            self.assertTrue(len(two.calls), 2)
+            self.assertTrue(two.call_count, 2)
             statuses = [response.status_code for _, response in two.calls]
             self.assertListEqual(statuses, [409, 409])
 
@@ -224,8 +223,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
     def test_raising_content(self):
         with respx.HTTPXMock() as httpx_mock:
             url = "https://foo/bar/"
-            foobar = httpx_mock.get(url, content=ConnectTimeout())
-            with self.assertRaises(ConnectTimeout):
+            foobar = httpx_mock.get(url, content=httpx.ConnectTimeout())
+            with self.assertRaises(httpx.ConnectTimeout):
                 httpx.get(url)
 
         self.assertTrue(foobar.called)
@@ -300,7 +299,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
                 with self.assertRaises(ValueError):
                     httpx.get(url)
 
-            self.assertEqual(len(httpx_mock.calls), 1)
+            self.assertEqual(httpx_mock.stats.call_count, 1)
             request, response = httpx_mock.calls[-1]
             self.assertIsNotNone(request)
             self.assertIsNone(response)
@@ -371,7 +370,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             with respx.HTTPXMock(assert_all_mocked=True) as httpx_mock:
                 httpx.get("https://foo/bar/")
 
-        self.assertEqual(len(httpx_mock.calls), 0)
+        self.assertEqual(httpx_mock.stats.call_count, 0)
 
     def test_assert_all_mocked_disabled(self):
         with respx.HTTPXMock(assert_all_mocked=False) as httpx_mock:
@@ -411,7 +410,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertIsNone(request.pass_through)
 
     async def test_async_stats(self, backend=None):
-        with respx.mock():
+        with respx.mock() as m:
             url = "https://foo/bar/1/"
             respx.get(re.compile("http://some/url"))
             respx.delete("http://some/url")
@@ -420,8 +419,10 @@ class HTTPXMockTestCase(asynctest.TestCase):
             foobar2 = respx.delete(url, status_code=200, alias="del_foobar")
 
             self.assertFalse(foobar1.called)
-            self.assertEqual(len(foobar1.calls), 0)
-            self.assertEqual(len(respx.calls), 0)
+            self.assertEqual(foobar1.call_count, len(foobar1.calls))
+            self.assertEqual(foobar1.call_count, 0)
+            self.assertEqual(m.stats.call_count, len(respx.calls))
+            self.assertEqual(m.stats.call_count, 0)
 
             async with httpx.AsyncClient(backend=backend) as client:
                 get_response = await client.get(url)
@@ -429,8 +430,8 @@ class HTTPXMockTestCase(asynctest.TestCase):
 
             self.assertTrue(foobar1.called)
             self.assertTrue(foobar2.called)
-            self.assertEqual(len(foobar1.calls), 1)
-            self.assertEqual(len(foobar2.calls), 1)
+            self.assertEqual(foobar1.call_count, 1)
+            self.assertEqual(foobar2.call_count, 1)
 
             _request, _response = foobar1.calls[-1]
             self.assertIsInstance(_request, httpx.AsyncRequest)
@@ -452,7 +453,7 @@ class HTTPXMockTestCase(asynctest.TestCase):
             self.assertEqual(_response.content, del_response.content)
             self.assertEqual(id(_response), id(del_response))
 
-            self.assertEqual(len(respx.calls), 2)
+            self.assertEqual(respx.stats.call_count, 2)
             self.assertEqual(respx.calls[0], foobar1.calls[-1])
             self.assertEqual(respx.calls[1], foobar2.calls[-1])