Skip to content

Commit ab1d005

Browse files
alexgromeroSamRemis
authored andcommitted
Add testing utilities to smithy-http (smithy-lang#588)
1 parent dc6efc1 commit ab1d005

File tree

7 files changed

+346
-0
lines changed

7 files changed

+346
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "feature",
3+
"description": "Added `MockHTTPClient` for testing SDK clients without making real HTTP requests."
4+
}

packages/smithy-http/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,44 @@
11
# smithy-http
22

33
This package provides primitives and interfaces for http functionality in tooling generated by Smithy.
4+
5+
---
6+
7+
## Testing
8+
9+
The `smithy_http.testing` module provides shared utilities for testing HTTP functionality in smithy-python clients.
10+
11+
### MockHTTPClient
12+
13+
The `MockHTTPClient` allows you to test smithy-python clients without making actual network calls. It implements the `HTTPClient` interface and provides configurable responses for functional testing.
14+
15+
#### Basic Usage
16+
17+
```python
18+
from smithy_http.testing import MockHTTPClient
19+
20+
# Create mock client and configure responses
21+
mock_client = MockHTTPClient()
22+
mock_client.add_response(
23+
status=200,
24+
headers=[("Content-Type", "application/json")],
25+
body=b'{"message": "success"}'
26+
)
27+
28+
# Use with your smithy-python client
29+
config = Config(transport=mock_client)
30+
client = TestSmithyServiceClient(config=config)
31+
32+
# Test your client logic
33+
result = await client.some_operation({"input": "data"})
34+
35+
# Inspect what requests were made
36+
assert mock_client.call_count == 1
37+
captured_request = mock_client.captured_requests[0]
38+
assert result.message == "success"
39+
```
40+
41+
### Utilities
42+
43+
- `create_test_request()`: Helper for creating test HTTPRequest objects
44+
- `MockHTTPClientError`: Exception raised when no responses are queued
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Shared utilities for testing smithy-python clients with an HTTP transport."""
5+
6+
from .mockhttp import MockHTTPClient, MockHTTPClientError
7+
from .utils import create_test_request
8+
9+
__all__ = (
10+
"MockHTTPClient",
11+
"MockHTTPClientError",
12+
"create_test_request",
13+
)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from collections import deque
5+
from copy import copy
6+
from typing import Any
7+
8+
from smithy_core.aio.utils import async_list
9+
10+
from smithy_http import tuples_to_fields
11+
from smithy_http.aio import HTTPResponse
12+
from smithy_http.aio.interfaces import HTTPClient, HTTPRequest
13+
from smithy_http.interfaces import HTTPClientConfiguration, HTTPRequestConfiguration
14+
15+
16+
class MockHTTPClient(HTTPClient):
17+
"""Implementation of :py:class:`.interfaces.HTTPClient` solely for testing purposes.
18+
19+
Simulates HTTP request/response behavior. Responses are queued in FIFO order and
20+
requests are captured for inspection.
21+
"""
22+
23+
def __init__(
24+
self,
25+
*,
26+
client_config: HTTPClientConfiguration | None = None,
27+
) -> None:
28+
"""
29+
:param client_config: Configuration that applies to all requests made with this
30+
client.
31+
"""
32+
self._client_config = client_config
33+
self._response_queue: deque[dict[str, Any]] = deque()
34+
self._captured_requests: list[HTTPRequest] = []
35+
36+
def add_response(
37+
self,
38+
status: int = 200,
39+
headers: list[tuple[str, str]] | None = None,
40+
body: bytes = b"",
41+
) -> None:
42+
"""Queue a response for the next request.
43+
44+
:param status: HTTP status code.
45+
:param headers: HTTP response headers as list of (name, value) tuples.
46+
:param body: Response body as bytes.
47+
"""
48+
self._response_queue.append(
49+
{
50+
"status": status,
51+
"headers": headers or [],
52+
"body": body,
53+
}
54+
)
55+
56+
async def send(
57+
self,
58+
request: HTTPRequest,
59+
*,
60+
request_config: HTTPRequestConfiguration | None = None,
61+
) -> HTTPResponse:
62+
"""Send HTTP request and return configured response.
63+
64+
:param request: The request including destination URI, fields, payload.
65+
:param request_config: Configuration specific to this request.
66+
:returns: Pre-configured HTTP response from the queue.
67+
:raises MockHTTPClientError: If no responses are queued.
68+
"""
69+
self._captured_requests.append(copy(request))
70+
71+
# Return next queued response or raise error
72+
if self._response_queue:
73+
response_data = self._response_queue.popleft()
74+
return HTTPResponse(
75+
status=response_data["status"],
76+
fields=tuples_to_fields(response_data["headers"]),
77+
body=async_list([response_data["body"]]),
78+
reason=None,
79+
)
80+
else:
81+
raise MockHTTPClientError(
82+
"No responses queued in MockHTTPClient. Use add_response() to queue responses."
83+
)
84+
85+
@property
86+
def call_count(self) -> int:
87+
"""The number of requests made to this client."""
88+
return len(self._captured_requests)
89+
90+
@property
91+
def captured_requests(self) -> list[HTTPRequest]:
92+
"""The list of all requests captured by this client."""
93+
return self._captured_requests.copy()
94+
95+
def __deepcopy__(self, memo: Any) -> "MockHTTPClient":
96+
return self
97+
98+
99+
class MockHTTPClientError(Exception):
100+
"""Exception raised by MockHTTPClient for test setup issues."""
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from smithy_core import URI
5+
6+
from smithy_http import tuples_to_fields
7+
from smithy_http.aio import HTTPRequest
8+
9+
10+
def create_test_request(
11+
method: str = "GET",
12+
host: str = "test.aws.dev",
13+
path: str | None = None,
14+
headers: list[tuple[str, str]] | None = None,
15+
body: bytes = b"",
16+
) -> HTTPRequest:
17+
"""Create test HTTPRequest with defaults.
18+
19+
:param method: HTTP method (GET, POST, etc.)
20+
:param host: Host name (e.g., "test.aws.dev")
21+
:param path: Optional path (e.g., "/users")
22+
:param headers: Optional headers as list of (name, value) tuples
23+
:param body: Request body as bytes
24+
:return: Configured HTTPRequest for testing
25+
"""
26+
return HTTPRequest(
27+
destination=URI(host=host, path=path),
28+
method=method,
29+
fields=tuples_to_fields(headers or []),
30+
body=body,
31+
)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import pytest
5+
from smithy_http.testing import MockHTTPClient, MockHTTPClientError, create_test_request
6+
7+
8+
async def test_default_response():
9+
# Test error when no responses are queued
10+
mock_client = MockHTTPClient()
11+
request = create_test_request()
12+
13+
with pytest.raises(MockHTTPClientError, match="No responses queued"):
14+
await mock_client.send(request)
15+
16+
17+
async def test_queued_responses_fifo():
18+
# Test responses are returned in FIFO order
19+
mock_client = MockHTTPClient()
20+
mock_client.add_response(status=404, body=b"not found")
21+
mock_client.add_response(status=500, body=b"server error")
22+
23+
request = create_test_request()
24+
25+
response1 = await mock_client.send(request)
26+
assert response1.status == 404
27+
assert await response1.consume_body_async() == b"not found"
28+
29+
response2 = await mock_client.send(request)
30+
assert response2.status == 500
31+
assert await response2.consume_body_async() == b"server error"
32+
33+
assert mock_client.call_count == 2
34+
35+
36+
async def test_captured_requests():
37+
# Test all requests are captured for inspection
38+
mock_client = MockHTTPClient()
39+
mock_client.add_response()
40+
mock_client.add_response()
41+
42+
request1 = create_test_request(
43+
method="GET",
44+
host="test.aws.dev",
45+
)
46+
request2 = create_test_request(
47+
method="POST",
48+
host="test.aws.dev",
49+
body=b'{"name": "test"}',
50+
)
51+
52+
await mock_client.send(request1)
53+
await mock_client.send(request2)
54+
55+
captured = mock_client.captured_requests
56+
assert len(captured) == 2
57+
assert captured[0].method == "GET"
58+
assert captured[1].method == "POST"
59+
assert captured[1].body == b'{"name": "test"}'
60+
61+
62+
async def test_response_headers():
63+
# Test response headers are properly set
64+
mock_client = MockHTTPClient()
65+
mock_client.add_response(
66+
status=201,
67+
headers=[
68+
("Content-Type", "application/json"),
69+
("X-Amz-Custom", "test"),
70+
],
71+
body=b'{"id": 123}',
72+
)
73+
request = create_test_request()
74+
response = await mock_client.send(request)
75+
76+
assert response.status == 201
77+
assert "Content-Type" in response.fields
78+
assert response.fields["Content-Type"].as_string() == "application/json"
79+
assert "X-Amz-Custom" in response.fields
80+
assert response.fields["X-Amz-Custom"].as_string() == "test"
81+
82+
83+
async def test_call_count_tracking():
84+
# Test call count is tracked correctly
85+
mock_client = MockHTTPClient()
86+
mock_client.add_response()
87+
mock_client.add_response()
88+
89+
request = create_test_request()
90+
91+
assert mock_client.call_count == 0
92+
93+
await mock_client.send(request)
94+
assert mock_client.call_count == 1
95+
96+
await mock_client.send(request)
97+
assert mock_client.call_count == 2
98+
99+
100+
async def test_captured_requests_copy():
101+
# Test that captured_requests returns a copy to prevent modifications
102+
mock_client = MockHTTPClient()
103+
mock_client.add_response()
104+
105+
request = create_test_request()
106+
107+
await mock_client.send(request)
108+
109+
captured1 = mock_client.captured_requests
110+
captured2 = mock_client.captured_requests
111+
112+
# Should be different list objects
113+
assert captured1 is not captured2
114+
# But with same content
115+
assert len(captured1) == len(captured2) == 1
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from smithy_http.testing import create_test_request
5+
6+
7+
def test_create_test_request_defaults():
8+
request = create_test_request()
9+
10+
assert request.method == "GET"
11+
assert request.destination.host == "test.aws.dev"
12+
assert request.destination.path is None
13+
assert request.body == b""
14+
assert len(request.fields) == 0
15+
16+
17+
def test_create_test_request_custom_values():
18+
request = create_test_request(
19+
method="POST",
20+
host="api.example.com",
21+
path="/users",
22+
headers=[
23+
("Content-Type", "application/json"),
24+
("Authorization", "AWS4-HMAC-SHA256"),
25+
],
26+
body=b'{"name": "test"}',
27+
)
28+
29+
assert request.method == "POST"
30+
assert request.destination.host == "api.example.com"
31+
assert request.destination.path == "/users"
32+
assert request.body == b'{"name": "test"}'
33+
34+
assert "Content-Type" in request.fields
35+
assert request.fields["Content-Type"].as_string() == "application/json"
36+
assert "Authorization" in request.fields
37+
assert request.fields["Authorization"].as_string() == "AWS4-HMAC-SHA256"
38+
39+
40+
def test_create_test_request_empty_headers():
41+
request = create_test_request(headers=[])
42+
assert len(request.fields) == 0

0 commit comments

Comments
 (0)