From 30256144865955b034bbccf0ca2a0017c9a9211e Mon Sep 17 00:00:00 2001 From: Shubham-Padkonde Date: Thu, 24 Sep 2026 02:27:51 +0000 Subject: [PATCH 1/2] fix: compare query params order-insensitively in assert_call_count assert_call_count compared the full URL string verbatim, so asserting against http://example.com/api?a=1&b=2 would fail when requests built the URL as ?b=2&a=1 (preserving the dict's insertion order). Fix by splitting each URL into its base and a sorted list of (key, value) pairs before comparing, matching the same order-independent semantics that query_string_matcher and query_param_matcher already use. Closes #822 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_012acaiEt2JyZdUxjQndUWQL --- responses/__init__.py | 13 ++++++++++++- responses/tests/test_responses.py | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/responses/__init__.py b/responses/__init__.py index 3b1947ac..61c06a60 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -1250,11 +1250,22 @@ def stop(self, allow_assert: bool = True) -> None: ) def assert_call_count(self, url: str, count: int) -> bool: + target = _ensure_url_default_path(url) + target_parts = urlsplit(str(target)) + target_base = urlunsplit(target_parts._replace(query="")) + target_qsl = sorted(parse_qsl(target_parts.query, keep_blank_values=True)) + + def _url_matches_target(request_url: str) -> bool: + req_parts = urlsplit(request_url) + req_base = urlunsplit(req_parts._replace(query="")) + req_qsl = sorted(parse_qsl(req_parts.query, keep_blank_values=True)) + return req_base == target_base and req_qsl == target_qsl + call_count = len( [ 1 for call in self.calls - if call.request.url == _ensure_url_default_path(url) + if _url_matches_target(call.request.url) ] ) if call_count == count: diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index 106d8c60..808104b4 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -2088,6 +2088,24 @@ def run(): assert_reset() +def test_assert_call_count_query_param_order(): + """assert_call_count matches URLs with query params in any order.""" + + @responses.activate + def run(): + responses.add(responses.GET, "http://example.com/api", body="ok") + + # Make a request; requests preserves the insertion order of params. + requests.get("http://example.com/api", params={"b": "2", "a": "1"}) + # The stored URL is ?b=2&a=1. Asserting with reversed order must still pass. + assert responses.assert_call_count("http://example.com/api?a=1&b=2", 1) is True + # Asserting with the original order must also pass. + assert responses.assert_call_count("http://example.com/api?b=2&a=1", 1) is True + + run() + assert_reset() + + def test_call_count_with_matcher(): @responses.activate def run(): From 1d9d8b32f4fd5ec24cac86387ebd795d14f21ce7 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Thu, 24 Sep 2026 21:05:34 +0530 Subject: [PATCH 2/2] test: cover nested query parameters in call counts --- CHANGES | 3 +++ responses/__init__.py | 10 ++++------ responses/tests/test_responses.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index 59fe2d25..8f0de226 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,9 @@ Unreleased ---------- +* Match query parameters independently of order in ``assert_call_count``, + including repeated values and bracket-encoded nested keys. See #822. + * Added Python 3.14 support (classifiers, tox envlist, and CI test matrix). See #810 * Match response ``Content-Type`` header names case-insensitively so lowercase headers do not produce duplicate content types or incorrect response decoding. diff --git a/responses/__init__.py b/responses/__init__.py index 61c06a60..b786547f 100644 --- a/responses/__init__.py +++ b/responses/__init__.py @@ -1255,18 +1255,16 @@ def assert_call_count(self, url: str, count: int) -> bool: target_base = urlunsplit(target_parts._replace(query="")) target_qsl = sorted(parse_qsl(target_parts.query, keep_blank_values=True)) - def _url_matches_target(request_url: str) -> bool: + def _url_matches_target(request_url: Optional[str]) -> bool: + if request_url is None: + return False req_parts = urlsplit(request_url) req_base = urlunsplit(req_parts._replace(query="")) req_qsl = sorted(parse_qsl(req_parts.query, keep_blank_values=True)) return req_base == target_base and req_qsl == target_qsl call_count = len( - [ - 1 - for call in self.calls - if _url_matches_target(call.request.url) - ] + [1 for call in self.calls if _url_matches_target(call.request.url)] ) if call_count == count: return True diff --git a/responses/tests/test_responses.py b/responses/tests/test_responses.py index 808104b4..471e8bdd 100644 --- a/responses/tests/test_responses.py +++ b/responses/tests/test_responses.py @@ -2106,6 +2106,34 @@ def run(): assert_reset() +@pytest.mark.parametrize( + "query", + [ + "filter[name]=alice&filter[roles][]=reader&filter[roles][]=writer", + "filter[roles][]=writer&filter[name]=alice&filter[roles][]=reader", + "filter%5Broles%5D%5B%5D=reader&filter%5Broles%5D%5B%5D=writer&filter%5Bname%5D=alice", + ], +) +def test_assert_call_count_nested_query_params(query): # type: ignore[misc] + with responses.RequestsMock() as mock: + url = "http://example.com/api" + mock.get(url, body="ok") + requests.get( + url, + params={"filter[name]": "alice", "filter[roles][]": ["writer", "reader"]}, + ) + + assert mock.assert_call_count(f"{url}?{query}", 1) is True + # Missing, changed, or duplicated nested values must not match. + for other in ( + "filter[name]=alice&filter[roles][]=reader", + "filter[name]=bob&filter[roles][]=writer&filter[roles][]=reader", + "filter[name]=alice&filter[roles][]=writer" + "&filter[roles][]=reader&filter[roles][]=reader", + ): + assert mock.assert_call_count(f"{url}?{other}", 0) is True + + def test_call_count_with_matcher(): @responses.activate def run():