Skip to content

Commit af33176

Browse files
mwdd146980claude
andcommitted
Add MockHTTPResponse for library-agnostic HTTP response mocking
MockHTTPResponse implements HTTPResponseProtocol without depending on requests or httpx. It supports the full response API (.json(), .text, .content, .status_code, .headers, .cookies, .elapsed), streaming via iter_content() and iter_lines(), raise_for_status() using HTTPStatusError, and context manager usage. Demonstrates usage by migrating test_authtoken.py from MockResponse to MockHTTPResponse — a drop-in replacement with protocol compliance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ba73983 commit af33176

4 files changed

Lines changed: 224 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add MockHTTPResponse for library-agnostic HTTP response mocking in tests.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Testing utilities for HTTP client mocking."""
5+
6+
import json
7+
from io import BytesIO
8+
from typing import Any, Iterator
9+
10+
__all__ = ['MockHTTPResponse']
11+
12+
13+
class MockHTTPResponse:
14+
"""Library-agnostic mock HTTP response implementing HTTPResponseProtocol."""
15+
16+
def __init__(
17+
self,
18+
content: str | bytes = '',
19+
status_code: int = 200,
20+
headers: dict[str, str] | None = None,
21+
json_data: dict[str, Any] | None = None,
22+
file_path: str | None = None,
23+
cookies: dict[str, str] | None = None,
24+
elapsed_seconds: float = 0.1,
25+
normalize_content: bool = True,
26+
):
27+
if json_data is not None:
28+
content = json.dumps(json_data)
29+
if headers is None:
30+
headers = {}
31+
headers.setdefault('Content-Type', 'application/json')
32+
elif file_path is not None:
33+
# Open in binary mode to handle both text and binary files correctly
34+
# This prevents encoding errors and platform-specific newline translation
35+
with open(file_path, 'rb') as f:
36+
content = f.read()
37+
38+
if normalize_content and (
39+
(isinstance(content, str) and content.startswith('\n'))
40+
or (isinstance(content, bytes) and content.startswith(b'\n'))
41+
):
42+
content = content[1:]
43+
44+
self._content = content.encode('utf-8') if isinstance(content, str) else content
45+
self.status_code = status_code
46+
self.headers = headers or {}
47+
self.cookies = cookies or {}
48+
49+
from datetime import timedelta
50+
51+
self.elapsed = timedelta(seconds=elapsed_seconds)
52+
self._stream = BytesIO(self._content)
53+
self._stream_consumed = False
54+
55+
def mock_getpeercert(self, binary_form=False):
56+
return b'mock-cert' if binary_form else {}
57+
58+
self.raw = type(
59+
'MockRaw',
60+
(),
61+
{
62+
'connection': type(
63+
'MockConnection', (), {'sock': type('MockSocket', (), {'getpeercert': mock_getpeercert})()}
64+
)()
65+
},
66+
)()
67+
68+
@property
69+
def content(self) -> bytes:
70+
return self._content
71+
72+
@property
73+
def text(self) -> str:
74+
return self._content.decode('utf-8')
75+
76+
def json(self, **kwargs: Any) -> Any:
77+
return json.loads(self.text, **kwargs)
78+
79+
def raise_for_status(self) -> None:
80+
if self.status_code >= 400:
81+
from datadog_checks.base.utils.http_exceptions import HTTPStatusError
82+
83+
message = (
84+
f'{self.status_code} Client Error' if self.status_code < 500 else f'{self.status_code} Server Error'
85+
)
86+
raise HTTPStatusError(message, response=self)
87+
88+
def iter_content(self, chunk_size: int | None = None, decode_unicode: bool = False) -> Iterator[bytes | str]:
89+
# chunk_size=None means return the entire content as a single chunk (matches requests behavior)
90+
chunk_size = chunk_size if chunk_size is not None else len(self._content) or 1
91+
if not self._stream_consumed:
92+
self._stream.seek(0)
93+
while chunk := self._stream.read(chunk_size):
94+
# Decode to string when decode_unicode=True (matches requests behavior)
95+
yield chunk.decode('utf-8') if decode_unicode else chunk
96+
self._stream_consumed = True
97+
98+
def iter_lines(
99+
self, chunk_size: int | None = None, decode_unicode: bool = False, delimiter: bytes | str | None = None
100+
) -> Iterator[bytes | str]:
101+
# Handle string delimiter by converting to bytes
102+
if isinstance(delimiter, str):
103+
delimiter = delimiter.encode('utf-8')
104+
delimiter = delimiter or b'\n'
105+
106+
if not self._stream_consumed:
107+
self._stream.seek(0)
108+
109+
lines = self._stream.read().split(delimiter)
110+
# bytes.split() produces a trailing empty element when content ends with the
111+
# delimiter (e.g. b'a\nb\n'.split(b'\n') == [b'a', b'b', b'']). requests uses
112+
# splitlines() for the default case which does not have this behavior, so we
113+
# strip the trailing empty element to match.
114+
if lines and not lines[-1]:
115+
lines.pop()
116+
for line in lines:
117+
# Decode to string when decode_unicode=True (matches requests behavior)
118+
yield line.decode('utf-8') if decode_unicode else line
119+
120+
self._stream_consumed = True
121+
122+
def __enter__(self) -> 'MockHTTPResponse':
123+
return self
124+
125+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool | None:
126+
self._stream.close()
127+
return None

datadog_checks_base/tests/base/utils/http/test_authtoken.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010

1111
from datadog_checks.base import ConfigurationError
1212
from datadog_checks.base.utils.http import DEFAULT_EXPIRATION, RequestsWrapper
13+
from datadog_checks.base.utils.http_testing import MockHTTPResponse
1314
from datadog_checks.base.utils.time import get_timestamp
15+
16+
# Note: MockHTTPResponse is a drop-in replacement for dev.http.MockResponse
17+
# with protocol compliance and httpx compatibility
1418
from datadog_checks.dev import TempDir
1519
from datadog_checks.dev.fs import read_file, write_file
16-
from datadog_checks.dev.http import MockResponse
1720

1821
from .common import DEFAULT_OPTIONS, FIXTURE_PATH
1922

@@ -607,14 +610,14 @@ def login(*args, **kwargs):
607610
assert isinstance(decoded['exp'], int)
608611
assert abs(decoded['exp'] - (get_timestamp() + exp)) < 10
609612

610-
return MockResponse(json_data={'token': 'auth-token'})
611-
return MockResponse(status_code=404)
613+
return MockHTTPResponse(json_data={'token': 'auth-token'})
614+
return MockHTTPResponse(status_code=404)
612615

613616
def auth(*args, **kwargs):
614617
if args[0] == 'https://leader.mesos/service/some-service':
615618
assert kwargs['headers']['Authorization'] == 'token=auth-token'
616-
return MockResponse(json_data={})
617-
return MockResponse(status_code=404)
619+
return MockHTTPResponse(json_data={})
620+
return MockHTTPResponse(status_code=404)
618621

619622
with mock.patch('requests.post', side_effect=login), mock.patch('requests.Session.get', side_effect=auth):
620623
http = RequestsWrapper(instance, init_config)
@@ -726,7 +729,7 @@ def raise_error_once(*args, **kwargs):
726729
if counter['errors'] <= 1:
727730
raise Exception
728731

729-
return MockResponse()
732+
return MockHTTPResponse()
730733

731734
expected_headers = {'Authorization': 'Bearer secret2'}
732735
expected_headers.update(DEFAULT_OPTIONS['headers'])
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
"""Tests for HTTP testing utilities.
5+
6+
Verifies MockHTTPResponse implementation details:
7+
- Default status_code=200
8+
- json_data auto-sets Content-Type header
9+
- raise_for_status() logic for 4xx/5xx codes
10+
- iter_lines() preserves empty lines (matches requests behavior)
11+
- Raw response mock structure for certificate access
12+
- Leading newline normalization
13+
"""
14+
15+
import pytest
16+
17+
from datadog_checks.base.utils.http_exceptions import HTTPStatusError
18+
from datadog_checks.base.utils.http_testing import MockHTTPResponse
19+
20+
21+
class TestMockHTTPResponseBasics:
22+
"""Test basic MockHTTPResponse functionality."""
23+
24+
def test_default_status_code(self):
25+
"""Default status code is 200."""
26+
response = MockHTTPResponse(content='test')
27+
28+
assert response.status_code == 200
29+
30+
31+
class TestMockHTTPResponseJSON:
32+
"""Test JSON response functionality."""
33+
34+
def test_json_with_custom_headers(self):
35+
"""json_data sets Content-Type but preserves other headers."""
36+
headers = {'X-Custom': 'value'}
37+
response = MockHTTPResponse(json_data={'key': 'value'}, headers=headers)
38+
39+
assert response.headers['Content-Type'] == 'application/json'
40+
assert response.headers['X-Custom'] == 'value'
41+
42+
43+
class TestMockHTTPResponseStatus:
44+
"""Test raise_for_status functionality."""
45+
46+
def test_client_error_raises(self):
47+
"""4xx status codes raise HTTPStatusError."""
48+
response = MockHTTPResponse(content='Not Found', status_code=404)
49+
50+
with pytest.raises(HTTPStatusError) as exc_info:
51+
response.raise_for_status()
52+
53+
assert '404 Client Error' in str(exc_info.value)
54+
assert exc_info.value.response is response
55+
56+
def test_server_error_raises(self):
57+
"""5xx status codes raise HTTPStatusError."""
58+
response = MockHTTPResponse(content='Server Error', status_code=500)
59+
60+
with pytest.raises(HTTPStatusError) as exc_info:
61+
response.raise_for_status()
62+
63+
assert '500 Server Error' in str(exc_info.value)
64+
assert exc_info.value.response is response
65+
66+
67+
class TestMockHTTPResponseStreaming:
68+
"""Test streaming functionality (iter_content, iter_lines)."""
69+
70+
def test_iter_lines_preserves_empty_lines(self):
71+
"""Empty lines are preserved but trailing delimiter does not produce an extra element."""
72+
content = 'line1\n\nline3\n'
73+
response = MockHTTPResponse(content=content)
74+
75+
lines = list(response.iter_lines())
76+
assert lines == [b'line1', b'', b'line3']
77+
78+
79+
class TestMockHTTPResponseNormalization:
80+
"""Test content normalization."""
81+
82+
def test_normalize_leading_newline(self):
83+
"""Leading newline is removed by default."""
84+
content = '\nActual content'
85+
response = MockHTTPResponse(content=content)
86+
87+
assert response.text == 'Actual content'

0 commit comments

Comments
 (0)