Repository: getsentry/responses
Pull request #398 (2021-08-29 11:16:16)

Add flag to allow automatic calculation of the Content-Length header
To be correct, the Content-Length header must be calculated from the
binary representation of the body. This is calculated as late as
possible by calling _handle_body(), and this is all done privately
within the module. Therefore, it's easiest if this is done in a
centralised place as late as possible by this module.

Every attempt I made at implementing this by extended the relevant
code just resulted in oodles of unnecessarily duplicated code compared
to what is possible by just integrating it directly.

I know this was previously tackled six years ago in #70. In that PR,
the concern was that the header was being added unconditionally. I
have mitigated this problem in my PR by controlling the new behaviour
with a flag. There are still plenty of situations where you wouldn't
want the Content-Length header to be set, so this new behaviour is
fully backwards-compatible.

codecov[bot]: # [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
> Merging [#398](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (58b63de) into [master](https://codecov.io/gh/getsentry/responses/commit/449e4914a48d62d06c925f00cd9addcc1e009cc9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) (449e491) will **increase** coverage by `0.17%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/getsentry/responses/pull/398/graphs/tree.svg?width=650&height=150&src=pr&token=x2tAGR7rxE&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)

```diff
@@            Coverage Diff             @@
##           master     #398      +/-   ##
==========================================
+ Coverage   96.46%   96.63%   +0.17%     
==========================================
  Files           3        3              
  Lines        1358     1428      +70     
==========================================
+ Hits         1310     1380      +70     
  Misses         48       48              
```


| [Impacted Files](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Coverage Δ | |
|---|---|---|
| [responses/\_\_init\_\_.py](https://app.codecov.io/gh/getsentry/responses/compare/398/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL19faW5pdF9fLnB5) | `97.36% <100.00%> (+0.02%)` | :arrow_up: |
| [responses/test\_responses.py](https://app.codecov.io/gh/getsentry/responses/compare/398/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-cmVzcG9uc2VzL3Rlc3RfcmVzcG9uc2VzLnB5) | `98.32% <100.00%> (+0.12%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Last update [449e491...58b63de](https://app.codecov.io/gh/getsentry/responses/compare/398?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).

djmattyg007: @markstory @beliaev-maksim I've updated this PR with code style changes recommended by black.

Patch:
diff --git a/.gitignore b/.gitignore
index 6bd96e5..3534373 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
 .coverage
 .DS_Store
 .idea
+.env
+venv
 *.db
 *.egg-info
 *.pyc
diff --git a/README.rst b/README.rst
index 20f5f27..e076796 100644
--- a/README.rst
+++ b/README.rst
@@ -125,6 +125,9 @@ headers (``dict``)
 stream (``bool``)
     Disabled by default. Indicates the response should use the streaming API.
 
+auto_calculate_content_length (``bool``)
+    Disabled by default. Automatically calculates the length of a supplied string or JSON body.
+
 match (``list``)
     A list of callbacks to match requests based on request body contents.
 
@@ -142,6 +145,7 @@ other formats.
 
     import responses
     import requests
+    from responses import matchers
 
     @responses.activate
     def test_calc_api():
@@ -150,15 +154,44 @@ other formats.
             url='http://calc.com/sum',
             body="4",
             match=[
-                responses.urlencoded_params_matcher({"left": "1", "right": "3"})
+                matchers.urlencoded_params_matcher({"left": "1", "right": "3"})
             ]
         )
         requests.post("http://calc.com/sum", data={"left": 1, "right": 3})
 
-Matching JSON encoded data can be done with ``responses.json_params_matcher()``.
+Matching JSON encoded data can be done with ``matchers.json_params_matcher()``.
 If your application uses other encodings you can build your own matcher that
 returns ``True`` or ``False`` if the request parameters match. Your matcher can
-expect a ``request_body`` parameter to be provided by responses.
+expect a ``request`` parameter to be provided by responses.
+
+Similarly, you can use the ``matchers.query_param_matcher`` function to match
+against the ``params`` request parameter.
+Note, you must set ``match_querystring=False``
+
+.. code-block:: python
+
+    import responses
+    import requests
+    from responses import matchers
+
+    @responses.activate
+    def test_calc_api():
+        url = "http://example.com/test"
+        params = {"hello": "world", "I am": "a big test"}
+        responses.add(
+            method=responses.GET,
+            url=url,
+            body="test",
+            match=[matchers.query_param_matcher(params)],
+            match_querystring=False,
+        )
+
+        resp = requests.get(url, params=params)
+
+        constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world"
+        assert resp.url == constructed_url
+        assert resp.request.url == constructed_url
+        assert resp.request.params == params
 
 Dynamic Responses
 -----------------
diff --git a/responses/__init__.py b/responses/__init__.py
index 68fd7a3..f278937 100644
--- a/responses/__init__.py
+++ b/responses/__init__.py
@@ -15,6 +15,9 @@ from requests.adapters import HTTPAdapter
 from requests.exceptions import ConnectionError
 from requests.sessions import REDIRECT_STATI
 from requests.utils import cookiejar_from_dict
+from responses.matchers import json_params_matcher as _json_params_matcher
+from responses.matchers import urlencoded_params_matcher as _urlencoded_params_matcher
+from warnings import warn
 
 try:
     from collections.abc import Sequence, Sized
@@ -59,11 +62,6 @@ except AttributeError:
     # Python 3.7
     Pattern = re.Pattern
 
-try:
-    from json.decoder import JSONDecodeError
-except ImportError:
-    JSONDecodeError = ValueError
-
 UNSET = object()
 
 Call = namedtuple("Call", ["request", "response"])
@@ -73,6 +71,22 @@ _real_send = HTTPAdapter.send
 logger = logging.getLogger("responses")
 
 
+def urlencoded_params_matcher(params):
+    warn(
+        "Function is deprecated. Use 'from responses.matchers import urlencoded_params_matcher'",
+        DeprecationWarning,
+    )
+    return _urlencoded_params_matcher(params)
+
+
+def json_params_matcher(params):
+    warn(
+        "Function is deprecated. Use 'from responses.matchers import json_params_matcher'",
+        DeprecationWarning,
+    )
+    return _json_params_matcher(params)
+
+
 def _is_string(s):
     return isinstance(s, six.string_types)
 
@@ -240,33 +254,6 @@ def _handle_body(body):
 _unspecified = object()
 
 
-def urlencoded_params_matcher(params):
-    def match(request_body):
-        return (
-            params is None
-            if request_body is None
-            else sorted(params.items()) == sorted(parse_qsl(request_body))
-        )
-
-    return match
-
-
-def json_params_matcher(params):
-    def match(request_body):
-        try:
-            if isinstance(request_body, bytes):
-                request_body = request_body.decode("utf-8")
-            return (
-                params is None
-                if request_body is None
-                else params == json_module.loads(request_body)
-            )
-        except JSONDecodeError:
-            return False
-
-    return match
-
-
 class BaseResponse(object):
     content_type = None
     headers = None
@@ -345,12 +332,14 @@ class BaseResponse(object):
         else:
             return False
 
-    def _body_matches(self, match, request_body):
+    @staticmethod
+    def _req_attr_matches(match, request):
         for matcher in match:
-            if not matcher(request_body):
-                return False
+            valid, reason = matcher(request)
+            if not valid:
+                return False, reason
 
-        return True
+        return True, ""
 
     def get_headers(self):
         headers = HTTPHeaderDict()  # Duplicate headers are legal
@@ -370,8 +359,9 @@ class BaseResponse(object):
         if not self._url_matches(self.url, request.url, self.match_querystring):
             return False, "URL does not match"
 
-        if not self._body_matches(self.match, request.body):
-            return False, "Parameters do not match"
+        valid, reason = self._req_attr_matches(self.match, request)
+        if not valid:
+            return False, "Parameters do not match. " + reason
 
         return True, ""
 
@@ -387,6 +377,7 @@ class Response(BaseResponse):
         headers=None,
         stream=False,
         content_type=UNSET,
+        auto_calculate_content_length=False,
         **kwargs
     ):
         # if we were passed a `json` argument,
@@ -408,6 +399,7 @@ class Response(BaseResponse):
         self.headers = headers
         self.stream = stream
         self.content_type = content_type
+        self.auto_calculate_content_length = auto_calculate_content_length
         super(Response, self).__init__(method, url, **kwargs)
 
     def get_response(self, request):
@@ -417,6 +409,15 @@ class Response(BaseResponse):
         headers = self.get_headers()
         status = self.status
         body = _handle_body(self.body)
+
+        if (
+            self.auto_calculate_content_length
+            and isinstance(body, BufferIO)
+            and "Content-Length" not in headers
+        ):
+            content_length = len(body.getvalue())
+            headers["Content-Length"] = str(content_length)
+
         return HTTPResponse(
             status=status,
             reason=six.moves.http_client.responses.get(status),
@@ -717,9 +718,9 @@ class RequestsMock(object):
         return params
 
     def _on_request(self, adapter, request, **kwargs):
+        request.params = self._parse_request_params(request.path_url)
         match, match_failed_reasons = self._find_match(request)
         resp_callback = self.response_callback
-        request.params = self._parse_request_params(request.path_url)
 
         if match is None:
             if any(
diff --git a/responses/__init__.pyi b/responses/__init__.pyi
index 571b5e1..583414e 100644
--- a/responses/__init__.pyi
+++ b/responses/__init__.pyi
@@ -5,22 +5,24 @@ from typing import (
     Iterator,
     Mapping,
     Optional,
-    Pattern,
     NamedTuple,
     Protocol,
     TypeVar,
+    Dict,
+    List,
+    Tuple,
+    Union
 )
 from io import BufferedReader, BytesIO
 from re import Pattern
 from requests.adapters import HTTPResponse, PreparedRequest
 from requests.cookies import RequestsCookieJar
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
 from typing_extensions import Literal
 from unittest import mock as std_mock
 from urllib.parse import quote as quote
 from urllib3.response import HTTPHeaderDict
+from .matchers import urlencoded_params_matcher, json_params_matcher
 
-JSONDecodeError = ValueError
 
 def _clean_unicode(url: str) -> str: ...
 def _cookies_from_headers(headers: Dict[str, str]) -> RequestsCookieJar: ...
@@ -36,12 +38,7 @@ def _is_string(s: Union[Pattern[str], str]) -> bool: ...
 def get_wrapped(
     func: Callable[..., Any], responses: RequestsMock
 ) -> Callable[..., Any]: ...
-def json_params_matcher(
-    params: Optional[Dict[str, Any]]
-) -> Callable[..., Any]: ...
-def urlencoded_params_matcher(
-    params: Optional[Dict[str, str]]
-) -> Callable[..., Any]: ...
+
 
 class Call(NamedTuple):
     request: PreparedRequest
@@ -75,7 +72,7 @@ class BaseResponse:
     ) -> None: ...
     def __eq__(self, other: Any) -> bool: ...
     def __ne__(self, other: Any) -> bool: ...
-    def _body_matches(
+    def _req_attr_matches(
         self, match: List[Callable[..., Any]], request_body: Optional[Union[bytes, str]]
     ) -> bool: ...
     def _should_match_querystring(
@@ -95,6 +92,7 @@ class Response(BaseResponse):
     headers: Optional[Mapping[str, str]] = ...
     stream: bool = ...
     content_type: Optional[str] = ...
+    auto_calculate_content_length: bool = ...
     def __init__(
         self,
         method: str,
@@ -105,6 +103,7 @@ class Response(BaseResponse):
         headers: Optional[Mapping[str, str]] = ...,
         stream: bool = ...,
         content_type: Optional[str] = ...,
+        auto_calculate_content_length: bool = ...,
         match_querystring: bool = ...,
         match: List[Any] = ...,
     ) -> None: ...
@@ -193,6 +192,7 @@ class _Add(Protocol):
         headers: Optional[Mapping[str, str]] = ...,
         stream: bool = ...,
         content_type: Optional[str] = ...,
+        auto_calculate_content_length: bool = ...,
         adding_headers: Optional[Mapping[str, str]] = ...,
         match_querystring: bool = ...,
         match: List[Any] = ...,
diff --git a/responses/matchers.py b/responses/matchers.py
new file mode 100644
index 0000000..8508c96
--- /dev/null
+++ b/responses/matchers.py
@@ -0,0 +1,87 @@
+import six
+import json as json_module
+
+if six.PY2:
+    from urlparse import parse_qsl
+else:
+    from urllib.parse import parse_qsl
+
+try:
+    from json.decoder import JSONDecodeError
+except ImportError:
+    JSONDecodeError = ValueError
+
+
+def urlencoded_params_matcher(params):
+    """
+    Matches URL encoded data
+    :param params: (dict) data provided to 'data' arg of request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_body = request.body
+        valid = (
+            params is None
+            if request_body is None
+            else sorted(params.items()) == sorted(parse_qsl(request_body))
+        )
+        if not valid:
+            return False, "%s doesn't match %s" % (request_body, params)
+
+        return valid, ""
+
+    return match
+
+
+def json_params_matcher(params):
+    """
+    Matches JSON encoded data
+    :param params: (dict) JSON data provided to 'json' arg of request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_body = request.body
+        try:
+            if isinstance(request_body, bytes):
+                request_body = request_body.decode("utf-8")
+            valid = (
+                params is None
+                if request_body is None
+                else params == json_module.loads(request_body)
+            )
+            if not valid:
+                return False, "%s doesn't match %s" % (request_body, params)
+
+            return valid, ""
+        except JSONDecodeError:
+            return False, "JSONDecodeError: Cannot parse request.body"
+
+    return match
+
+
+def query_param_matcher(params):
+    """
+    Matcher to match 'params' argument in request
+    :param params: (dict), same as provided to request
+    :return: (func) matcher
+    """
+
+    def match(request):
+        request_params = request.params
+        valid = (
+            params is None
+            if request_params is None
+            else sorted(params.items()) == sorted(request_params.items())
+        )
+
+        if not valid:
+            return False, "%s doesn't match %s" % (
+                sorted(request_params.items()),
+                sorted(params.items()),
+            )
+
+        return valid, ""
+
+    return match
diff --git a/responses/matchers.pyi b/responses/matchers.pyi
new file mode 100644
index 0000000..8851303
--- /dev/null
+++ b/responses/matchers.pyi
@@ -0,0 +1,21 @@
+from typing import (
+    Any,
+    Callable,
+    Optional,
+    Dict,
+)
+
+JSONDecodeError = ValueError
+
+
+def json_params_matcher(
+    params: Optional[Dict[str, Any]]
+) -> Callable[..., Any]: ...
+
+def urlencoded_params_matcher(
+    params: Optional[Dict[str, str]]
+) -> Callable[..., Any]: ...
+
+def query_param_matcher(
+    params: Optional[Dict[str, str]]
+) -> Callable[..., Any]: ...


Test patch:
diff --git a/responses/test_responses.py b/responses/test_responses.py
index 4821f56..44fc9ac 100644
--- a/responses/test_responses.py
+++ b/responses/test_responses.py
@@ -3,15 +3,18 @@
 from __future__ import absolute_import, print_function, division, unicode_literals
 
 import inspect
+import os
 import re
 import six
 from io import BufferedReader, BytesIO
+from sys import version_info
 
 import pytest
 import requests
 import responses
 from requests.exceptions import ConnectionError, HTTPError
-from responses import BaseResponse, Response
+from responses import BaseResponse, Response, matchers
+
 
 try:
     from mock import patch, Mock
@@ -530,8 +533,8 @@ def test_callback():
     }
     url = "http://example.com/"
 
-    def request_callback(request):
-        return (status, headers, body)
+    def request_callback(_request):
+        return status, headers, body
 
     @responses.activate
     def run():
@@ -573,7 +576,7 @@ def test_callback_exception_body():
     url = "http://example.com/"
 
     def request_callback(request):
-        return (200, {}, body)
+        return 200, {}, body
 
     @responses.activate
     def run():
@@ -595,8 +598,8 @@ def test_callback_no_content_type():
     headers = {"foo": "bar"}
     url = "http://example.com/"
 
-    def request_callback(request):
-        return (status, headers, body)
+    def request_callback(_request):
+        return status, headers, body
 
     @responses.activate
     def run():
@@ -887,17 +890,22 @@ def test_response_callback():
     assert_reset()
 
 
+@pytest.mark.skipif(six.PY2, reason="re.compile works differntly in PY2")
 def test_response_filebody():
     """ Adds the possibility to use actual (binary) files as responses """
 
     def run():
+        current_file = os.path.abspath(__file__)
         with responses.RequestsMock() as m:
-            with open("README.rst", "r") as out:
+            with open(current_file, "r") as out:
                 m.add(responses.GET, "http://example.com", body=out.read(), stream=True)
                 resp = requests.get("http://example.com")
-            with open("README.rst", "r") as out:
+            with open(current_file, "r") as out:
                 assert resp.text == out.read()
 
+    run()
+    assert_reset()
+
 
 def test_assert_all_requests_are_fired():
     def request_callback(request):
@@ -1081,6 +1089,114 @@ def test_legacy_adding_headers():
     assert_reset()
 
 
+def test_auto_calculate_content_length_string_body():
+    @responses.activate
+    def run():
+        url = "http://example.com/"
+        responses.add(
+            responses.GET, url, body="test", auto_calculate_content_length=True
+        )
+        resp = requests.get(url)
+        assert_response(resp, "test")
+        assert resp.headers["Content-Length"] == "4"
+
+    run()
+    assert_reset()
+
+
+def test_auto_calculate_content_length_bytes_body():
+    @responses.activate
+    def run():
+        url = "http://example.com/"
+        responses.add(
+            responses.GET, url, body=b"test bytes", auto_calculate_content_length=True
+        )
+        resp = requests.get(url)
+        assert_response(resp, "test bytes")
+        assert resp.headers["Content-Length"] == "10"
+
+    run()
+    assert_reset()
+
+
+def test_auto_calculate_content_length_json_body():
+    @responses.activate
+    def run():
+        content_type = "application/json"
+
+        url = "http://example.com/"
+        responses.add(
+            responses.GET,
+            url,
+            json={"message": "success"},
+            auto_calculate_content_length=True,
+        )
+        resp = requests.get(url)
+        assert_response(resp, '{"message": "success"}', content_type)
+        assert resp.headers["Content-Length"] == "22"
+
+        url = "http://example.com/1/"
+        responses.add(responses.GET, url, json=[], auto_calculate_content_length=True)
+        resp = requests.get(url)
+        assert_response(resp, "[]", content_type)
+        assert resp.headers["Content-Length"] == "2"
+
+    run()
+    assert_reset()
+
+
+def test_auto_calculate_content_length_unicode_body():
+    @responses.activate
+    def run():
+        url = "http://example.com/test"
+        responses.add(
+            responses.GET, url, body="михољско лето", auto_calculate_content_length=True
+        )
+        resp = requests.get(url)
+        assert_response(resp, "михољско лето", content_type="text/plain; charset=utf-8")
+        assert resp.headers["Content-Length"] == "25"
+
+    run()
+    assert_reset()
+
+
+def test_auto_calculate_content_length_doesnt_work_for_buffered_reader_body():
+    @responses.activate
+    def run():
+        url = "http://example.com/test"
+        responses.add(
+            responses.GET,
+            url,
+            body=BufferedReader(BytesIO(b"testing")),  # type: ignore
+            auto_calculate_content_length=True,
+        )
+        resp = requests.get(url)
+        assert_response(resp, "testing")
+        assert "Content-Length" not in resp.headers
+
+    run()
+    assert_reset()
+
+
+def test_auto_calculate_content_length_doesnt_override_existing_value():
+    @responses.activate
+    def run():
+        url = "http://example.com/"
+        responses.add(
+            responses.GET,
+            url,
+            body="test",
+            headers={"Content-Length": "2"},
+            auto_calculate_content_length=True,
+        )
+        resp = requests.get(url)
+        assert_response(resp, "test")
+        assert resp.headers["Content-Length"] == "2"
+
+    run()
+    assert_reset()
+
+
 def test_multiple_responses():
     @responses.activate
     def run():
@@ -1304,26 +1420,25 @@ def test_assert_call_count(url):
 
 def test_request_matches_post_params():
     @responses.activate
-    def run():
+    def run(deprecated):
+        if deprecated:
+            json_params_matcher = getattr(responses, "json_params_matcher")
+            urlencoded_params_matcher = getattr(responses, "urlencoded_params_matcher")
+        else:
+            json_params_matcher = matchers.json_params_matcher
+            urlencoded_params_matcher = matchers.urlencoded_params_matcher
+
         responses.add(
             method=responses.POST,
             url="http://example.com/",
             body="one",
-            match=[
-                responses.json_params_matcher(
-                    {"page": {"name": "first", "type": "json"}}
-                )
-            ],
+            match=[json_params_matcher({"page": {"name": "first", "type": "json"}})],
         )
         responses.add(
             method=responses.POST,
             url="http://example.com/",
             body="two",
-            match=[
-                responses.urlencoded_params_matcher(
-                    {"page": "second", "type": "urlencoded"}
-                )
-            ],
+            match=[urlencoded_params_matcher({"page": "second", "type": "urlencoded"})],
         )
 
         resp = requests.request(
@@ -1342,8 +1457,9 @@ def test_request_matches_post_params():
         )
         assert_response(resp, "one")
 
-    run()
-    assert_reset()
+    for depr in [True, False]:
+        run(deprecated=depr)
+        assert_reset()
 
 
 def test_request_matches_empty_body():
@@ -1353,14 +1469,14 @@ def test_request_matches_empty_body():
             method=responses.POST,
             url="http://example.com/",
             body="one",
-            match=[responses.json_params_matcher(None)],
+            match=[matchers.json_params_matcher(None)],
         )
 
         responses.add(
             method=responses.POST,
             url="http://example.com/",
             body="two",
-            match=[responses.urlencoded_params_matcher(None)],
+            match=[matchers.urlencoded_params_matcher(None)],
         )
 
         resp = requests.request("POST", "http://example.com/")
@@ -1377,6 +1493,39 @@ def test_request_matches_empty_body():
     assert_reset()
 
 
+def test_request_matches_params():
+    @responses.activate
+    def run():
+        url = "http://example.com/test"
+        params = {"hello": "world", "I am": "a big test"}
+        responses.add(
+            method=responses.GET,
+            url=url,
+            body="test",
+            match=[matchers.query_param_matcher(params)],
+            match_querystring=False,
+        )
+
+        # exchange parameter places for the test
+        params = {
+            "I am": "a big test",
+            "hello": "world",
+        }
+        resp = requests.get(url, params=params)
+
+        if six.PY3 and version_info[1] >= 7:
+            # only after py 3.7 dictionaries are ordered, so we can check URL
+            constructed_url = r"http://example.com/test?I+am=a+big+test&hello=world"
+            assert resp.url == constructed_url
+            assert resp.request.url == constructed_url
+
+        resp_params = getattr(resp.request, "params")
+        assert resp_params == params
+
+    run()
+    assert_reset()
+
+
 def test_fail_request_error():
     @responses.activate
     def run():
@@ -1385,15 +1534,68 @@ def test_fail_request_error():
         responses.add(
             "POST",
             "http://example.com",
-            match=[responses.urlencoded_params_matcher({"foo": "bar"})],
+            match=[matchers.urlencoded_params_matcher({"foo": "bar"})],
+        )
+        responses.add(
+            "POST",
+            "http://example.com",
+            match=[matchers.json_params_matcher({"fail": "json"})],
         )
 
         with pytest.raises(ConnectionError) as excinfo:
             requests.post("http://example.com", data={"id": "bad"})
+
         msg = str(excinfo.value)
         assert "- POST http://example1.com/ URL does not match" in msg
         assert "- GET http://example.com/ Method does not match" in msg
-        assert "- POST http://example.com/ Parameters do not match" in msg
+
+        if six.PY3:
+            assert "Parameters do not match. id=bad doesn't match {'foo': 'bar'}" in msg
+        else:
+            assert (
+                "Parameters do not match. id=bad doesn't match {u'foo': u'bar'}" in msg
+            )
+
+        assert (
+            "Parameters do not match. JSONDecodeError: Cannot parse request.body" in msg
+        )
+
+        # test query parameters
+        responses.add(
+            "GET",
+            "http://111.com",
+            match=[matchers.query_param_matcher({"my": "params"})],
+        )
+
+        responses.add(
+            method=responses.GET,
+            url="http://111.com/",
+            body="two",
+            match=[matchers.json_params_matcher({"page": "one"})],
+        )
+
+        with pytest.raises(ConnectionError) as excinfo:
+            requests.get("http://111.com", params={"id": "bad"}, json={"page": "two"})
+
+        msg = str(excinfo.value)
+        if six.PY3:
+            assert (
+                "Parameters do not match. [('id', 'bad')] doesn't match [('my', 'params')]"
+                in msg
+            )
+            assert (
+                """Parameters do not match. {"page": "two"} doesn't match {'page': 'one'}"""
+                in msg
+            )
+        else:
+            assert (
+                "Parameters do not match. [('id', 'bad')] doesn't match [(u'my', u'params')]"
+                in msg
+            )
+            assert (
+                """Parameters do not match. {"page": "two"} doesn't match {u'page': u'one'}"""
+                in msg
+            )
 
     run()
     assert_reset()