- 
                Notifications
    
You must be signed in to change notification settings  - Fork 24
 
Add testing utilities to smithy-http #588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: functional-testing-framework
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "type": "feature", | ||
| "description": "Added support for minimal components required for SDK functional testing" | ||
| } | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -1,3 +1,44 @@ | ||
| # smithy-http | ||
| 
     | 
||
| This package provides primitives and interfaces for http functionality in tooling generated by Smithy. | ||
| 
     | 
||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for adding usage documentation! Once we have documentation for Smithy Python, we should add a top-level section for testing that displays this information. Right now users have to stumble on   | 
||
| --- | ||
| 
     | 
||
| ## Testing | ||
| 
     | 
||
| The `smithy_http.testing` module provides shared utilities for testing HTTP functionality in smithy-python clients. | ||
| 
     | 
||
| ### MockHTTPClient | ||
| 
     | 
||
| 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. | ||
| 
     | 
||
| #### Basic Usage | ||
| 
     | 
||
| ```python | ||
| from smithy_http.testing import MockHTTPClient | ||
| 
     | 
||
| # Create mock client and configure responses | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response( | ||
| status=200, | ||
| headers=[("Content-Type", "application/json")], | ||
| body=b'{"message": "success"}' | ||
| ) | ||
| 
     | 
||
| # Use with your smithy-python client | ||
| config = Config(transport=mock_client) | ||
| client = TestSmithyServiceClient(config=config) | ||
| 
     | 
||
| # Test your client logic | ||
| result = await client.some_operation({"input": "data"}) | ||
| 
     | 
||
| # Inspect what requests were made | ||
| assert mock_client.call_count == 1 | ||
| captured_request = mock_client.captured_requests[0] | ||
| assert result.message == "success" | ||
| ``` | ||
| 
     | 
||
| ### Utilities | ||
| 
     | 
||
| - `create_test_request()`: Helper for creating test HTTPRequest objects | ||
| - `MockHTTPClientError`: Exception raised when no responses are queued | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| 
     | 
||
| """Shared utilities for smithy-python functional tests.""" | ||
| 
     | 
||
| from .mockhttp import MockHTTPClient, MockHTTPClientError | ||
| from .utils import create_test_request | ||
| 
     | 
||
| __version__ = "0.0.0" | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may have been left over from the   | 
||
| 
     | 
||
| __all__ = ( | ||
| "MockHTTPClient", | ||
| "MockHTTPClientError", | ||
| "create_test_request", | ||
| ) | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| 
     | 
||
| from collections import deque | ||
| from copy import copy | ||
| 
     | 
||
| from smithy_core.aio.utils import async_list | ||
| 
     | 
||
| from smithy_http import tuples_to_fields | ||
| from smithy_http.aio import HTTPResponse | ||
| from smithy_http.aio.interfaces import HTTPClient, HTTPRequest | ||
| from smithy_http.interfaces import HTTPClientConfiguration, HTTPRequestConfiguration | ||
| 
     | 
||
| 
     | 
||
| class MockHTTPClient(HTTPClient): | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. General question, not a suggestion: Do we expect users to create a new MockHTTPClient if they need an empty client instead of exposing APIs to clean up the responses and requests? Either works for me but wanted to know if that was considered.  | 
||
| """Implementation of :py:class:`.interfaces.HTTPClient` solely for testing purposes. | ||
| Simulates HTTP request/response behavior. | ||
| Responses are queued in FIFO order and requests are captured for inspection. | ||
| """ | ||
| 
     | 
||
| def __init__( | ||
| self, | ||
| *, | ||
| client_config: HTTPClientConfiguration | None = None, | ||
| ) -> None: | ||
| """ | ||
| :param client_config: Configuration that applies to all requests made with this | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FYI, I think we'll be using Google style docstrings moving forward (see #564). I think for now that would cause the CI to fail so this is more of a heads up.  | 
||
| client. | ||
| """ | ||
| self._client_config = client_config | ||
| self._response_queue: deque[HTTPResponse] = deque() | ||
| self._captured_requests: list[HTTPRequest] = [] | ||
| 
     | 
||
| def add_response( | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential idea for a helper function: it's pretty common in tests that we add multiple responses and test subsequent calls. It may be nice to have a helper function that sets a default response.  | 
||
| self, | ||
| status: int = 200, | ||
| headers: list[tuple[str, str]] | None = None, | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In line 49 we set   | 
||
| body: bytes = b"", | ||
| ) -> None: | ||
| """Queue a response for the next request. | ||
| :param status: HTTP status code (200, 404, 500, etc.) | ||
| :param headers: HTTP response headers as list of (name, value) tuples | ||
| :param body: Response body as bytes | ||
| """ | ||
| response = HTTPResponse( | ||
| status=status, | ||
| fields=tuples_to_fields(headers or []), | ||
| body=async_list([body]), | ||
| reason=None, | ||
| ) | ||
| self._response_queue.append(response) | ||
| 
     | 
||
| async def send( | ||
| self, | ||
| request: HTTPRequest, | ||
| *, | ||
| request_config: HTTPRequestConfiguration | None = None, | ||
| ) -> HTTPResponse: | ||
| """Send HTTP request and return configured response. | ||
| :param request: The request including destination URI, fields, payload. | ||
| :param request_config: Configuration specific to this request. | ||
| :returns: Pre-configured HTTP response from the queue. | ||
| :raises MockHTTPClientError: If no responses are queued. | ||
| """ | ||
| self._captured_requests.append(copy(request)) | ||
| 
     | 
||
| # Return next queued response or raise error | ||
| if self._response_queue: | ||
| return self._response_queue.popleft() | ||
| else: | ||
| raise MockHTTPClientError( | ||
| "No responses queued in MockHTTPClient. Use add_response() to queue responses." | ||
| ) | ||
| 
     | 
||
| @property | ||
| def call_count(self) -> int: | ||
| """The number of requests made to this client.""" | ||
| return len(self._captured_requests) | ||
| 
     | 
||
| @property | ||
| def captured_requests(self) -> list[HTTPRequest]: | ||
| """The list of all requests captured by this client.""" | ||
| return self._captured_requests.copy() | ||
| 
     | 
||
| 
     | 
||
| class MockHTTPClientError(Exception): | ||
| """Exception raised by MockHTTPClient for test setup issues.""" | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| 
     | 
||
| from smithy_core import URI | ||
| 
     | 
||
| from smithy_http import tuples_to_fields | ||
| from smithy_http.aio import HTTPRequest | ||
| 
     | 
||
| 
     | 
||
| def create_test_request( | ||
| method: str = "GET", | ||
| host: str = "test.aws.dev", | ||
| path: str | None = None, | ||
| headers: list[tuple[str, str]] | None = None, | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment as above.  | 
||
| body: bytes = b"", | ||
| ) -> HTTPRequest: | ||
| """Create test HTTPRequest with defaults. | ||
| :param method: HTTP method (GET, POST, etc.) | ||
| :param host: Host name (e.g., "test.aws.dev") | ||
| :param path: Optional path (e.g., "/users") | ||
| :param headers: Optional headers as list of (name, value) tuples | ||
| :param body: Request body as bytes | ||
| :return: Configured HTTPRequest for testing | ||
| """ | ||
| return HTTPRequest( | ||
| destination=URI(host=host, path=path), | ||
| method=method, | ||
| fields=tuples_to_fields(headers or []), | ||
| body=body, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| 
     | 
||
| import pytest | ||
| from smithy_http.testing import MockHTTPClient, MockHTTPClientError, create_test_request | ||
| 
     | 
||
| 
     | 
||
| async def test_default_response(): | ||
| # Test error when no responses are queued | ||
| mock_client = MockHTTPClient() | ||
| request = create_test_request() | ||
| 
     | 
||
| with pytest.raises(MockHTTPClientError, match="No responses queued"): | ||
| await mock_client.send(request) | ||
| 
     | 
||
| 
     | 
||
| async def test_queued_responses_fifo(): | ||
| # Test responses are returned in FIFO order | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response(status=404, body=b"not found") | ||
| mock_client.add_response(status=500, body=b"server error") | ||
| 
     | 
||
| request = create_test_request() | ||
| 
     | 
||
| response1 = await mock_client.send(request) | ||
| assert response1.status == 404 | ||
| assert await response1.consume_body_async() == b"not found" | ||
| 
     | 
||
| response2 = await mock_client.send(request) | ||
| assert response2.status == 500 | ||
| assert await response2.consume_body_async() == b"server error" | ||
| 
     | 
||
| assert mock_client.call_count == 2 | ||
| 
     | 
||
| 
     | 
||
| async def test_captures_requests(): | ||
| # Test all requests are captured for inspection | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response() | ||
| mock_client.add_response() | ||
| 
     | 
||
| request1 = create_test_request( | ||
| method="GET", | ||
| host="test.aws.dev", | ||
| ) | ||
| request2 = create_test_request( | ||
| method="POST", | ||
| host="test.aws.dev", | ||
| body=b'{"name": "test"}', | ||
| ) | ||
| 
     | 
||
| await mock_client.send(request1) | ||
| await mock_client.send(request2) | ||
| 
     | 
||
| captured = mock_client.captured_requests | ||
| assert len(captured) == 2 | ||
| assert captured[0].method == "GET" | ||
| assert captured[1].method == "POST" | ||
| assert captured[1].body == b'{"name": "test"}' | ||
| 
     | 
||
| 
     | 
||
| async def test_response_headers(): | ||
| # Test response headers are properly set | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response( | ||
| status=201, | ||
| headers=[ | ||
| ("Content-Type", "application/json"), | ||
| ("X-Amz-Custom", "test"), | ||
| ], | ||
| body=b'{"id": 123}', | ||
| ) | ||
| request = create_test_request() | ||
| response = await mock_client.send(request) | ||
| 
     | 
||
| assert response.status == 201 | ||
| assert "Content-Type" in response.fields | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. tiny nit: if we're checking   | 
||
| assert response.fields["Content-Type"].as_string() == "application/json" | ||
| assert response.fields["X-Amz-Custom"].as_string() == "test" | ||
| 
     | 
||
| 
     | 
||
| async def test_call_count_tracking(): | ||
| # Test call count is tracked correctly | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response() | ||
| mock_client.add_response() | ||
| 
     | 
||
| request = create_test_request() | ||
| 
     | 
||
| assert mock_client.call_count == 0 | ||
| 
     | 
||
| await mock_client.send(request) | ||
| assert mock_client.call_count == 1 | ||
| 
     | 
||
| await mock_client.send(request) | ||
| assert mock_client.call_count == 2 | ||
| 
     | 
||
| 
     | 
||
| async def test_captured_requests_copy(): | ||
| # Test that captured_requests returns a copy to prevent modifications | ||
| mock_client = MockHTTPClient() | ||
| mock_client.add_response() | ||
| 
     | 
||
| request = create_test_request() | ||
| 
     | 
||
| await mock_client.send(request) | ||
| 
     | 
||
| captured1 = mock_client.captured_requests | ||
| captured2 = mock_client.captured_requests | ||
| 
     | 
||
| # Should be different list objects | ||
| assert captured1 is not captured2 | ||
| # But with same content | ||
| assert len(captured1) == len(captured2) == 1 | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| 
     | 
||
| from smithy_http.testing import create_test_request | ||
| 
     | 
||
| 
     | 
||
| def test_create_test_request_defaults(): | ||
| request = create_test_request() | ||
| 
     | 
||
| assert request.method == "GET" | ||
| assert request.destination.host == "test.aws.dev" | ||
| assert request.destination.path is None | ||
| assert request.body == b"" | ||
| assert len(request.fields) == 0 | ||
| 
     | 
||
| 
     | 
||
| def test_create_test_request_custom_values(): | ||
| request = create_test_request( | ||
| method="POST", | ||
| host="api.example.com", | ||
| path="/users", | ||
| headers=[ | ||
| ("Content-Type", "application/json"), | ||
| ("Authorization", "AWS4-HMAC-SHA256"), | ||
| ], | ||
| body=b'{"name": "test"}', | ||
| ) | ||
| 
     | 
||
| assert request.method == "POST" | ||
| assert request.destination.host == "api.example.com" | ||
| assert request.destination.path == "/users" | ||
| assert request.body == b'{"name": "test"}' | ||
| 
     | 
||
| assert "Content-Type" in request.fields | ||
| assert request.fields["Content-Type"].as_string() == "application/json" | ||
| assert "Authorization" in request.fields | ||
| assert request.fields["Authorization"].as_string() == "AWS4-HMAC-SHA256" | ||
| 
     | 
||
| 
     | 
||
| def test_create_test_request_empty_headers(): | ||
| request = create_test_request(headers=[]) | ||
| assert len(request.fields) == 0 | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think we can be more specific in describing what exactly was added. We should mention that
MockHTTPClientwas added by name.