From d75250e33f8a8de330db7c9f01eb368907168b8e Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Thu, 26 Jun 2025 23:49:06 +0300 Subject: [PATCH 01/17] feat: Add aiohttp support --- README.md | 32 ++++++++++++++ multi_api_mocker/aiohttp_utils.py | 35 ++++++++++++++++ multi_api_mocker/contrib/pytest_plugin.py | 49 ++++++++++++++++++++++ setup.py | 4 +- tests/test_pytest/conftest.py | 19 +++++++-- tests/test_pytest/test_aiohttp_fixtures.py | 25 +++++++++++ 6 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 multi_api_mocker/aiohttp_utils.py create mode 100644 tests/test_pytest/test_aiohttp_fixtures.py diff --git a/README.md b/README.md index 7fc7ada..856c810 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,38 @@ def test_commit_and_push(setup_http_mocks): This setup allows you to define the mock responses directly in the test parameters, offering a straightforward and flexible way to mock multiple API calls within a single test case. +### Quick Start: `aiohttp` Usage + +This guide demonstrates how to quickly set up API mocks for `aiohttp` using `MockAPIResponse` with direct JSON responses in a pytest-parametrized test. + +```python +import pytest +from aiohttp import ClientSession +from multi_api_mocker.definitions import MockAPIResponse + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test", + method="GET", + json={"message": "Success"}, + status_code=200, + ) + ] + ], + indirect=True, +) +async def test_aiohttp_mocking(setup_aiohttp_mocks): + async with ClientSession() as session: + async with session.get("https://example.com/api/test") as response: + assert response.status == 200 + assert await response.json() == {"message": "Success"} +``` + ## API Reference ### MockAPIResponse Class diff --git a/multi_api_mocker/aiohttp_utils.py b/multi_api_mocker/aiohttp_utils.py new file mode 100644 index 0000000..bf728cd --- /dev/null +++ b/multi_api_mocker/aiohttp_utils.py @@ -0,0 +1,35 @@ +from typing import List +from aioresponses import aioresponses +from multi_api_mocker.definitions import MockAPIResponse + + +class AIOHTTPMockSet: + """ + A collection class that manages MockAPIResponse objects and integrates with the + aioresponses fixture. This class provides efficient access and iteration over + grouped API responses by their endpoint names, simplifying the process of setting + up and managing multiple mock responses in tests for aiohttp. + """ + + def __init__( + self, + api_responses: List[MockAPIResponse], + aiohttp_mock: aioresponses, + ): + self._response_registry = { + response.endpoint_name: response for response in api_responses + } + self.aiohttp_mock = aiohttp_mock + + def __getitem__(self, endpoint_name: str) -> MockAPIResponse: + return self._response_registry[endpoint_name] + + def __iter__(self): + return iter(self._response_registry.values()) + + def __len__(self): + return len(self._response_registry) + + def __repr__(self): + endpoint_names = ", ".join(self._response_registry.keys()) + return f"<{self.__class__.__name__} with endpoints: {endpoint_names}>" diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index a0eea2b..8261a0c 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -22,6 +22,14 @@ except ImportError: httpx_available = False +try: + from aioresponses import aioresponses # type: ignore # noqa: F401 + from ..aiohttp_utils import AIOHTTPMockSet # noqa: F401 + aiohttp_available = True +except ImportError: + aiohttp_available = False + + if requests_mock_available: @pytest.fixture(scope="function") @@ -147,3 +155,44 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): json=mock_definition.json, status_code=mock_definition.status_code, ) + +if aiohttp_available: + + @pytest.fixture + def aiohttp_mock_session(): + with aioresponses() as m: + yield m + + @pytest.fixture + def setup_aiohttp_mocks(aiohttp_mock_session, request) -> AIOHTTPMockSet: + mock_definitions: List[ + Union[MockAPIResponse, List[MockAPIResponse]] + ] = request.param + + for mock_definition in mock_definitions: + if isinstance(mock_definition, list): + for nested_mock_definition in mock_definition: + add_aiohttp_response(aiohttp_mock_session, nested_mock_definition) + else: + add_aiohttp_response(aiohttp_mock_session, mock_definition) + + yield AIOHTTPMockSet(mock_definitions, aiohttp_mock_session) + + def add_aiohttp_response(aiohttp_mock: aioresponses, mock_definition: MockAPIResponse): + if not isinstance(mock_definition, MockAPIResponse): + raise ValueError( + f"Unsupported mock definition type: {type(mock_definition)}" + ) + if mock_definition.exc: + aiohttp_mock.exception( + url=mock_definition.url, + method=mock_definition.method.upper(), + exception=mock_definition.exc, + ) + else: + aiohttp_mock.add( + url=mock_definition.url, + method=mock_definition.method.upper(), + payload=mock_definition.json, + status=mock_definition.status_code, + ) diff --git a/setup.py b/setup.py index c5502a5..94d6ba9 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ # Define the optional dependencies http_requirements = ["requests_mock>=1.9.3"] httpx_requirements = ["pytest-httpx>=0.21.0"] +aiohttp_requirements = ["aioresponses>=0.7.4"] test_requirements = [ "pytest>=3", @@ -40,7 +41,8 @@ extras_require={ "http": http_requirements, "httpx": httpx_requirements, - "all": http_requirements + httpx_requirements, + "aiohttp": aiohttp_requirements, + "all": http_requirements + httpx_requirements + aiohttp_requirements, }, license="MIT license", long_description=readme + "\n\n" + history, diff --git a/tests/test_pytest/conftest.py b/tests/test_pytest/conftest.py index 119211a..218d9d9 100644 --- a/tests/test_pytest/conftest.py +++ b/tests/test_pytest/conftest.py @@ -1,3 +1,16 @@ -from multi_api_mocker.contrib.pytest_plugin import setup_api_mocks # noqa: F401 -from multi_api_mocker.contrib.pytest_plugin import setup_http_mocks # noqa: F401 -from multi_api_mocker.contrib.pytest_plugin import setup_httpx_mocks # noqa: F401 +try: + from multi_api_mocker.contrib.pytest_plugin import setup_http_mocks # noqa: F401 +except ImportError: + pass + +try: + from multi_api_mocker.contrib.pytest_plugin import setup_httpx_mocks # noqa: F401 +except ImportError: + pass + +try: + from multi_api_mocker.contrib.pytest_plugin import setup_aiohttp_mocks # noqa: F401 +except ImportError: + pass + +pytest_plugins = "aioresponses" diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py new file mode 100644 index 0000000..6aa9bdc --- /dev/null +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -0,0 +1,25 @@ +import pytest +from aiohttp import ClientSession +from multi_api_mocker.definitions import MockAPIResponse + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test", + method="GET", + json={"message": "Success"}, + status_code=200, + ) + ] + ], + indirect=True, +) +async def test_aiohttp_mocking(setup_aiohttp_mocks): + async with ClientSession() as session: + async with session.get("https://example.com/api/test") as response: + assert response.status == 200 + assert await response.json() == {"message": "Success"} From 96cbd8210339c06125e0c00435fd083dba32eaf6 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Thu, 26 Jun 2025 23:53:23 +0300 Subject: [PATCH 02/17] docs: Restructure README for clarity --- README.md | 513 ++++++++++++++++-------------------------------------- 1 file changed, 153 insertions(+), 360 deletions(-) diff --git a/README.md b/README.md index 856c810..a421bdd 100644 --- a/README.md +++ b/README.md @@ -1,449 +1,242 @@ # Multi-API Mocker: Streamlined API Mocking for pytest - [![PyPI version](https://img.shields.io/pypi/v/multi-api-mocker.svg)](https://pypi.python.org/pypi/multi-api-mocker) +Multi-API Mocker is a Python utility designed to enhance and simplify the process of mocking multiple API calls in pytest tests. It provides a consistent and intuitive interface for mocking API responses, whether you're using `requests`, `httpx`, or `aiohttp`. -Multi-API Mocker is a Python utility designed to enhance and simplify the process of mocking multiple API calls in pytest tests. Developed as an extension for the [requests_mock](https://github.com/jamielennox/requests-mock) - package, this tool focuses on improving test readability, maintainability, and efficiency in scenarios requiring multiple API interactions. We extend special thanks to Jamie Lennox for his exceptional work on the requests_mock library, which has been fundamental in the development of this tool. Multi-API Mocker is an ideal solution for developers and testers who regularly work with complex API testing scenarios in pytest. - +This tool focuses on improving test readability, maintainability, and efficiency in scenarios requiring multiple API interactions, making it an ideal solution for developers and testers who work with complex API testing scenarios in pytest. -**Features**: +## Features - **Simplified Mock Management**: Organize and manage multiple API mocks in a clean and intuitive way. - **Enhanced Readability**: Keep your tests neat and readable by separating mock definitions from test logic. - **Flexible Response Handling**: Easily define and handle different response scenarios for each API endpoint. -- **Pytest Integration**: Seamlessly integrates with pytest, enhancing its capabilities for API mocking. +- **Seamless Pytest Integration**: Integrates with pytest fixtures, enhancing its capabilities for API mocking. - **Reduced Boilerplate**: Less repetitive code, focusing only on the specifics of each test case. - **Customizable Mocks**: Tailor your mocks to fit various testing scenarios with customizable response parameters. -Multi-API Mocker is a versatile package that seamlessly integrates with both `requests_mock` and `pytest_httpx`, providing a consistent and intuitive interface for mocking API responses in your tests. Whether you're using `requests` or `httpx` for making HTTP requests, Multi-API Mocker has you covered. Switching between the two libraries is a breeze, allowing you to adapt to your project's requirements with minimal effort. - -**Installation**: - -Multi-API Mocker offers flexible installation options depending on your project's needs. You can choose to install support for either `requests_mock` or `pytest_httpx`, or you can install both for maximum versatility. - -To install Multi-API Mocker with `requests_mock` support, run the following command in your terminal: +## Installation -```bash -pip install multi-api-mocker[http] -``` - -To install Multi-API Mocker with `pytest_httpx` support, use the following command: +Multi-API Mocker offers flexible installation options depending on your project's needs. You can install support for a specific library or all of them for maximum versatility. -```bash -pip install multi-api-mocker[httpx] -``` +- **For `requests` support:** + ```bash + pip install multi-api-mocker[http] + ``` -If you want to install Multi-API Mocker with support for both `requests_mock` and `pytest_httpx`, you can use the `all` extra: - -```bash -pip install multi-api-mocker[all] -``` +- **For `httpx` support:** + ```bash + pip install multi-api-mocker[httpx] + ``` -By specifying the appropriate extra during installation, you can ensure that only the necessary dependencies are installed based on your project's requirements. +- **For `aiohttp` support:** + ```bash + pip install multi-api-mocker[aiohttp] + ``` -Once installed, you can start using Multi-API Mocker in your tests to mock API responses effortlessly. The package provides a set of intuitive fixtures and utilities that work seamlessly with both `requests_mock` and `pytest_httpx`, allowing you to focus on writing comprehensive and maintainable tests. +- **For all supported libraries:** + ```bash + pip install multi-api-mocker[all] + ``` -### Quick Start: Simple Usage +## Core Concept: Defining Mock Responses -This guide demonstrates how to quickly set up API mocks using `MockAPIResponse` with direct JSON responses in a pytest-parametrized test. This approach is suitable for scenarios where custom subclassing is not necessary. +The foundation of this library is the `MockAPIResponse` class, which serves as a blueprint for creating mock responses for your API endpoints. You can use it directly or subclass it to create reusable mock definitions. -#### Step 1: Import Necessary Modules +### Using `MockAPIResponse` Directly -First, import the required modules: +For simple cases, you can instantiate `MockAPIResponse` directly within your test parametrization. ```python from multi_api_mocker.definitions import MockAPIResponse -import pytest -import requests -``` - -#### Step 2: Define Your Test with Parametrized Mocks - -Now, define your test function and use `pytest.mark.parametrize` to inject the mock responses directly: -```python @pytest.mark.parametrize( "setup_http_mocks", [ - ( - [ - MockAPIResponse( - url="https://example.com/api/commit", - method="POST", - json={"message": "Commit successful", "commit_id": "abc123"}, - ), - MockAPIResponse( - url="https://example.com/api/push", - method="POST", - json={"message": "Push successful", "push_id": "xyz456"}, - ), - ] - ) - ], - indirect=True, -) -def test_commit_and_push(setup_http_mocks): - # Perform the commit API call - commit_response = requests.post("https://example.com/api/commit") - assert commit_response.json() == { - "message": "Commit successful", - "commit_id": "abc123", - } - - # Perform the push API call - push_response = requests.post("https://example.com/api/push") - assert push_response.json() == {"message": "Push successful", "push_id": "xyz456"} -``` - -This setup allows you to define the mock responses directly in the test parameters, offering a straightforward and flexible way to mock multiple API calls within a single test case. - -### Quick Start: `aiohttp` Usage - -This guide demonstrates how to quickly set up API mocks for `aiohttp` using `MockAPIResponse` with direct JSON responses in a pytest-parametrized test. - -```python -import pytest -from aiohttp import ClientSession -from multi_api_mocker.definitions import MockAPIResponse - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "setup_aiohttp_mocks", - [ - [ + ([ MockAPIResponse( - url="https://example.com/api/test", + url="https://example.com/api/data", method="GET", - json={"message": "Success"}, - status_code=200, + json={"message": "Success!"}, + status_code=200 ) - ] + ]) ], - indirect=True, + indirect=True ) -async def test_aiohttp_mocking(setup_aiohttp_mocks): - async with ClientSession() as session: - async with session.get("https://example.com/api/test") as response: - assert response.status == 200 - assert await response.json() == {"message": "Success"} +def test_direct_mock(setup_http_mocks): + # Your test logic here + ... ``` -## API Reference - -### MockAPIResponse Class +### Subclassing `MockAPIResponse` for Reusability -The `MockAPIResponse` class is an essential component of the multi_api_mocker utility, serving as a blueprint for creating mock responses for API endpoints. It is designed to generate configurations that are directly passed to the `requests_mock` initializer, thereby streamlining the process of setting up mock responses in tests. This functionality is especially beneficial in scenarios involving multiple API interactions, as it allows for a structured, reusable approach to defining expected responses. - -#### Why Subclass MockAPIResponse? - -Subclassing `MockAPIResponse` is recommended for creating customized mock responses tailored to specific API endpoints. This method enhances reusability, reduces redundancy, and offers flexibility in modifying response attributes as needed in different test cases. Subclasses can set default values for common response properties, making it easier to simulate various API behaviors in a consistent manner. - -#### Example Subclasses - -Here's an example of a subclass representing a commit API endpoint: +For more complex or frequently used endpoints, subclassing `MockAPIResponse` is recommended. This enhances reusability and keeps your test definitions clean. ```python -class Commit(MockAPIResponse): - url = "https://example.com/api/commit" +# in tests/mocks.py +from multi_api_mocker.definitions import MockAPIResponse + +class UserProfile(MockAPIResponse): + url = "https://api.example.com/user/profile" method = "GET" default_status_code = 200 default_json = { - "id": "commit102", - "message": "Initial commit with project structure", - "author": "dev@example.com", - "timestamp": "2023-11-08T12:34:56Z", + "id": "user123", + "name": "Jane Doe", + "email": "jane.doe@example.com" } -``` -This subclass defines the default URL, HTTP method, status code, and JSON response for a commit endpoint. It can be instantiated directly in tests, or its attributes can be overridden to simulate different scenarios. +# in your test file +import mocks -For instance, to simulate a scenario where a commit is rejected: +def test_user_profile(setup_http_mocks): + # The mock is already set up by the fixture + response = requests.get("https://api.example.com/user/profile") + assert response.json()["name"] == "Jane Doe" -```python -Commit(json={"error": "commit rejected"}, status_code=400) +# To override defaults for a specific test: +@pytest.mark.parametrize( + "setup_http_mocks", + [([ + # Simulate a not found error + mocks.UserProfile(status_code=404, json={"error": "User not found"}) + ])], + indirect=True +) +def test_user_not_found(setup_http_mocks): + response = requests.get("https://api.example.com/user/profile") + assert response.status_code == 404 ``` -In this example, the `json` and `status_code` parameters override the defaults defined in the `Commit` class, allowing the test to simulate an error response. - -#### Class Attributes and Constructor Parameters - -1. **url** (`Union[str, re.Pattern]`): The default URL of the API endpoint. It can be a specific string or a regular expression pattern, allowing for versatile matching of endpoints. - -2. **method** (`str`): The default HTTP method (GET, POST, etc.) to be used for the mock response. This method will be applied unless explicitly overridden. - -3. **endpoint_name** (`str`): A human-readable identifier for the API endpoint. This name facilitates easy tracking and referencing of mock responses in tests. - -4. **default_status_code** (`Optional[int]`): Sets the standard HTTP status code for the response, such as 200 for successful requests or 404 for not found errors. - -5. **default_json** (`Optional[dict]`): The default JSON data to be returned in the response. It represents the typical response structure expected from the endpoint. - -6. **default_text** (`Optional[str]`): Default text to be returned when a non-JSON response is appropriate. - -7. **default_exc** (`Optional[Exception]`): An optional exception that, if set, will be raised by default when the endpoint is accessed. Useful for testing error handling. - -#### Constructor Parameters - -When creating an instance of `MockAPIResponse`, the following parameters can be specified to override the class-level defaults: - -1. **url** (`str`, optional): Overrides the default URL for the API endpoint. - -2. **method** (`str`, optional): Specifies a different HTTP method for the mock response. - -3. **status_code** (`int`, optional): Sets a specific HTTP status code for the instance, different from the class default. - -4. **json** (`dict`, optional): Provides JSON data for the response, overriding the default JSON. - -5. **partial_json** (`dict`, optional): Allows for partial updates to the default JSON data, useful for minor variations in response structure. - -6. **text** (`str`, optional): Overrides the default text response. - -7. **endpoint_name** (`str`, optional): Sets a specific endpoint name for the instance. - -8. **exc** (`Exception`, optional): Specifies an exception to be raised when the endpoint is accessed. - -9. **kwargs**: Additional keyword arguments for extended configurations or customizations. - -#### Usage and Behavior - -Subclassing and instantiating `MockAPIResponse` provides a powerful and flexible way to define and manage mock responses for API endpoints. These subclasses can be used directly or modified on-the-fly in tests, enabling developers to thoroughly test their applications against a wide range of API response scenarios. This approach keeps test code clean and focused, with mock logic encapsulated within the mock response classes. - -### `setup_http_mocks` Pytest Fixture - - -The `setup_http_mocks` fixture is an integral part of the multi_api_mocker utility, designed to work seamlessly with pytest and the requests_mock package. It provides a convenient way to organize and implement mock API responses in your tests. The fixture accepts a list of `MockAPIResponse` subclass instances, each representing a specific API response. This setup is ideal for tests involving multiple API calls, ensuring a clean separation between test logic and mock definitions. - -#### Purpose and Benefits +## Supported Libraries and Usage -Using `setup_http_mocks` streamlines the process of configuring mock responses in pytest. It enhances test readability and maintainability by: +Multi-API Mocker provides dedicated pytest fixtures for each supported HTTP client library. While the setup is similar, the returned "mock set" object differs slightly for each. -- **Separating Mocks from Test Logic**: Keeps your test functions clean and focused on the actual testing logic, avoiding clutter with mock setup details. -- **Reusable Mock Definitions**: Allows you to define mock responses in a centralized way, promoting reusability across different tests. -- **Flexible Response Simulation**: Facilitates the simulation of various API behaviors and scenarios, including error handling and edge cases. +### For `requests` -#### How It Works +- **Fixture:** `setup_http_mocks` +- **Returned Object:** `RequestsMockSet` -The fixture integrates with the `requests_mock` pytest fixture, which intercepts HTTP requests and provides mock responses as defined in the `MockAPIResponse` subclasses. When a test function is executed, the fixture: +This fixture integrates with the `requests-mock` library. -1. **Collects Mock Configurations**: Gathers the list of `MockAPIResponse` instances provided via pytest's parametrization. -2. **Registers Mock Responses**: Each mock response configuration is registered with the `requests_mock` instance, ensuring the appropriate mock response is returned for each API call in the test. -3. **Yields a `RequestsMockSet` Object**: Returns a `RequestsMockSet` instance, which contains the organized mock responses accessible by their endpoint names. - -#### Example Usage - -1. **Defining Mock Responses**: - - Create subclasses of `MockAPIResponse` for each API endpoint you need to mock. - - ```python - class Fork(MockAPIResponse): - url = "https://example.com/api/fork" - method = "POST" - # ... other default attributes ... - ``` - -2. **Using the Fixture in Tests**: - - Use `pytest.mark.parametrize` to pass the mock response subclasses to the test function. The `setup_http_mocks` fixture processes these and sets up the necessary mocks. - - ```python - @pytest.mark.parametrize( - "setup_http_mocks", - [([Fork(), Commit(), Push()])], - indirect=True - ) - def test_repository_workflow(setup_http_mocks): - mock_set = setup_http_mocks - # ... test logic using mock_set ... - ``` - - In this example, the test function `test_repository_workflow` receives a `RequestsMockSet` object containing the mocks for `Fork`, `Commit`, and `Push` endpoints. - - -### `RequestsMockSet` Class - - -The `RequestsMockSet` class in the multi_api_mocker utility is a practical and efficient way to manage multiple `MockAPIResponse` objects in your pytest tests. It is designed to store and organize these mock responses, allowing you to easily access and manipulate them as needed. - -#### Constructor Parameters - -- **api_responses** (`List[MockAPIResponse]`): A list of `MockAPIResponse` objects, each representing a specific mock for an API endpoint. -- **requests_mock** (`Optional[Mocker]`): The `requests_mock` fixture instance, which is automatically passed by the `setup_http_mocks` fixture. It's used for registering the mock API responses. - -#### Functionality - -`RequestsMockSet` is particularly useful when you are dealing with a series of API calls in a test and need to reference specific mock responses repeatedly. It helps maintain clean and readable test code by centralizing the mock definitions. - -#### How to Use `RequestsMockSet` - -1. **Initialization**: `RequestsMockSet` is initialized with a list of `MockAPIResponse` instances. These can represent different API calls you intend to mock in your tests. - - ```python - mock_set = RequestsMockSet([Fork(), Commit(), Push(), ForcePush()]) - print(mock_set) - # Output: - ``` - -2. **Accessing Specific Mocks**: To access a specific mock response, use the endpoint name as the key: - - ```python - fork_response = mock_set["Fork"] - # Output: Fork(url=https://example.com/api/fork, method=POST, status_code=200) - ``` - -3. **Iterating Over Mocks**: You can iterate over all the mocks in the `RequestsMockSet`: - - ```python - for mock in mock_set: - # Perform checks or operations on each mock response - ``` +**Example:** +```python +import requests +from . import mocks # Assuming mocks.py with a UserProfile subclass -4. **Converting to a List**: To get a list of all mock responses in the `RequestsMockSet`, simply use `list(mock_set)`: +@pytest.mark.parametrize( + "setup_http_mocks", + [([mocks.UserProfile()])], + indirect=True +) +def test_requests_example(setup_http_mocks): + response = requests.get("https://api.example.com/user/profile") + assert response.status_code == 200 + assert response.json()["id"] == "user123" - ```python - all_mocks = list(mock_set) - ``` + # The RequestsMockSet gives you access to the underlying matcher + matcher = setup_http_mocks.get_matcher("UserProfile") + assert matcher.call_count == 1 +``` -### Usage of `MockAPIResponse` in Different Testing Scenarios +### For `httpx` -#### Multiple Parametrized Tests with API Calls in Sequence +- **Fixture:** `setup_httpx_mocks` +- **Returned Object:** `HTTPXMockSet` -This approach utilizes multiple parametrized tests to handle different sequences of API calls, demonstrating how to set up and assert behaviors for a series of API interactions under varying conditions. This approach ensures comprehensive coverage of different response scenarios, making it particularly useful when your system is expected to provide a consistent output structure to the client, regardless of the specific API response status. +This fixture integrates with `pytest-httpx`. A key difference from `requests-mock` is that `httpx` requests are created just-in-time when they are executed. The `HTTPXMockSet` helps you inspect these requests *after* they have been made. +**Example:** ```python +import httpx +from . import mocks + @pytest.mark.parametrize( - "setup_http_mocks", - [ - # Scenario 1: Push fails with a 400 error - ( - [ - mocks.Fork(), - mocks.Commit(), - mocks.Push(status_code=400, json={"error": "Push failed"}), - ] - ), - # Scenario 2: Force push succeeds after a failed push - ( - [ - mocks.Fork(), - mocks.Commit(), - mocks.ForcePush(status_code=400, json={"error": "Force Push failed"}), - ] - ), - ], - indirect=True, + "setup_httpx_mocks", + [([mocks.UserProfile()])], + indirect=True ) -def test_multiple_scenarios(setup_http_mocks): - mock_set = setup_http_mocks - # ... Perform API calls and assert responses for each mock ... +def test_httpx_example(setup_httpx_mocks): + with httpx.Client() as client: + response = client.get("https://api.example.com/user/profile") + + assert response.status_code == 200 + assert response.json()["id"] == "user123" + + # Use get_request() to inspect the request after it was made + request = setup_httpx_mocks.get_request("UserProfile") + assert request.method == "GET" ``` -In both scenarios, the structure of the system's response to the client is expected to remain consistent, whether the underlying API operation succeeds or fails. By grouping error and success scenarios in this way, you can comprehensively test how your system handles different API response conditions while maintaining a consistent output to the client. This method streamlines the testing process, ensuring that all necessary scenarios are covered efficiently. +### For `aiohttp` +- **Fixture:** `setup_aiohttp_mocks` +- **Returned Object:** `AIOHTTPMockSet` -#### Simulating API Exceptions -This scenario shows how to simulate exceptions in API responses. The test is set up to expect an exception for a specific API call. +This fixture integrates with `aioresponses`. Similar to `httpx`, `aiohttp` requests are asynchronous and handled just-in-time. +**Example:** ```python +import aiohttp +import pytest +from . import mocks + +@pytest.mark.asyncio @pytest.mark.parametrize( - "setup_http_mocks", - [([mocks.Fork(), mocks.Commit(), mocks.Push(exc=RequestException)])], + "setup_aiohttp_mocks", + [([mocks.UserProfile()])], indirect=True ) -def test_api_exception_handling(setup_http_mocks): - mock_set = setup_http_mocks - # Handling and asserting the exception - ... +async def test_aiohttp_example(setup_aiohttp_mocks): + async with aiohttp.ClientSession() as session: + async with session.get("https://api.example.com/user/profile") as response: + assert response.status == 200 + data = await response.json() + assert data["id"] == "user123" ``` -#### Partial JSON Updates -Here, the focus is on testing API calls where only a part of the JSON response needs to be altered. This approach avoids the need for creating multiple mock subclasses for minor variations. +## Advanced Usage + +### Simulating Exceptions + +You can simulate network errors or other exceptions by passing an `exc` argument. ```python +import requests +from requests.exceptions import ConnectTimeout + @pytest.mark.parametrize( "setup_http_mocks", - [([mocks.Fork(), mocks.Commit(), mocks.Push(partial_json={"id": "partial_id"})])], + [([ + mocks.UserProfile(exc=ConnectTimeout("Connection timed out")) + ])], indirect=True ) -def test_api_with_partial_json(setup_http_mocks): - mock_set = setup_http_mocks - - response = requests.post("https://example.com/api/push") - expected_json = mock_set["Push"].json - expected_json["id"] = "partial_id" - - assert response.status_code == 200 - assert response.json() == expected_json +def test_with_exception(setup_http_mocks): + with pytest.raises(ConnectTimeout): + requests.get("https://api.example.com/user/profile") ``` -### Flexible Parametrization with Indirect Fixture Usage +### Partial JSON Updates -In this setup, the focus is on demonstrating the flexibility provided by using `indirect=["setup_http_mocks"]` in parametrized tests. This method allows for the inclusion of multiple, varied parameters into the test function. The `user_email` in the given example is just one instance of how diverse parameters can be effectively integrated into tests. -Parametrized tests can be enhanced by combining them with the `setup_http_mocks` fixture using `indirect=["setup_http_mocks"]`. This approach allows for the introduction of additional parameters (`user_email` in this case) that can be varied across different test cases. +For minor variations in a response, use `partial_json` to update only specific fields of the `default_json`. ```python @pytest.mark.parametrize( - "user_email, setup_http_mocks", - [ - # Example configuration with additional parameter 'user_email' - ("example@email.com", [mocks.Fork(), mocks.Commit(), mocks.Push()]), - # Another configuration with a different 'user_email' and modified API response - ( - "another@email.com", - [ - mocks.Fork(), - mocks.Commit(), - mocks.Push(json={"message": "Custom message"}), - ], - ), - ], - indirect=["setup_http_mocks"], + "setup_http_mocks", + [([ + mocks.UserProfile(partial_json={"name": "John Smith"}) + ])], + indirect=True ) -def test_flexible_parametrization(user_email, setup_http_mocks): - mock_set = setup_http_mocks - # Perform API calls and assertions here +def test_with_partial_json(setup_http_mocks): + response = requests.get("https://api.example.com/user/profile") + # The rest of the default_json from the UserProfile class remains unchanged + assert response.json()["name"] == "John Smith" + assert response.json()["id"] == "user123" ``` -This structure is highly adaptable and can be utilized for a wide range of scenarios where test configurations need to change dynamically based on different input parameters. The `user_email` parameter is just an illustrative example; the same technique can apply to any number of parameters, such as user roles, request data, or environmental configurations, providing a robust framework for comprehensive and varied testing scenarios. - - -### Note on using Multi-API Mocker with `pytest_httpx` - -When using Multi-API Mocker with `pytest_httpx`, you can use the created definitions interchangeably without any changes to your test code. However, it's important to note that `pytest_httpx` works differently compared to `requests_mock` in terms of when the requests are created. - -With `pytest_httpx`, the requests are not created until they are actually executed during the test. To accommodate this behavior, Multi-API Mocker introduces a new `HTTPXMockSet` collection specifically designed for `pytest_httpx`. - -The `HTTPXMockSet` collection provides methods and utilities tailored to work with `pytest_httpx`'s deferred request creation. It allows you to retrieve the actual `httpx` requests after they have been executed, enabling you to perform assertions on the request properties. - -When using Multi-API Mocker with `pytest_httpx`, you can access the `HTTPXMockSet` collection through the `setup_httpx_mocks` fixture, which is the equivalent of the `setup_http_mocks` fixture used with `requests_mock`. - -Keep this difference in mind when working with `pytest_httpx`, and refer to the Multi-API Mocker documentation for specific examples and guidance on using the `HTTPXMockSet` collection effectively in your tests. - - -### Deprecation Warnings - -With the introduction of `httpx` support in Multi-API Mocker, the naming convention for the fixtures has been updated to provide clarity and consistency. The `setup_api_mocks` fixture, which was previously used for mocking API responses with `requests_mock`, has been renamed to `setup_http_mocks`. - -However, to ensure backward compatibility and minimize disruption to existing projects, the `setup_api_mocks` fixture is still available and fully functional. When you use `setup_api_mocks` in your tests, it internally points to the `setup_http_mocks` fixture, so your existing code should continue to work without any modifications. - -It's important to note that the `setup_api_mocks` fixture is now considered deprecated and will be removed in a future release of Multi-API Mocker. We strongly recommend updating your tests to use the new `setup_http_mocks` fixture to ensure future compatibility and to avoid any potential confusion. - -Upgrading to the new fixture is straightforward and only requires renaming the fixture in your test code. Instead of using `setup_api_mocks`, simply replace it with `setup_http_mocks`: - -```python -# Old usage (deprecated) -def test_example(setup_api_mocks): - # Test code using setup_api_mocks - -# New usage (recommended) -def test_example(setup_http_mocks): - # Test code using setup_http_mocks -``` - -By making this simple change, you'll be aligned with the latest naming convention and ensure that your tests are future-proof. - -We recommend gradually updating your tests to use `setup_http_mocks` instead of `setup_api_mocks` to avoid using the deprecated fixture in the long run. This will help keep your test suite up to date and make it easier to maintain. +## Deprecation Warnings -If you have any questions or need assistance with the upgrade process, please refer to the Multi-API Mocker documentation or reach out to our support channels for guidance. +The `setup_api_mocks` fixture is deprecated and will be removed in a future release. Please use `setup_http_mocks` for `requests` mocking instead. \ No newline at end of file From 8a93a4d62bda60a48fa1f73bdd8f5eb258460811 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:02:18 +0300 Subject: [PATCH 03/17] docs: Further clarify MockSet behavior for all libraries --- README.md | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a421bdd..aac7bab 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Multi-API Mocker offers flexible installation options depending on your project' pip install multi-api-mocker[all] ``` -## Core Concept: Defining Mock Responses +## Core Concept: Defining Mock Responses (`MockAPIResponse`) -The foundation of this library is the `MockAPIResponse` class, which serves as a blueprint for creating mock responses for your API endpoints. You can use it directly or subclass it to create reusable mock definitions. +The foundation of this library is the `MockAPIResponse` class, which serves as a universal blueprint for creating mock responses for your API endpoints, regardless of the underlying HTTP client library. You can use it directly or subclass it to create reusable mock definitions. ### Using `MockAPIResponse` Directly @@ -51,7 +51,7 @@ For simple cases, you can instantiate `MockAPIResponse` directly within your tes from multi_api_mocker.definitions import MockAPIResponse @pytest.mark.parametrize( - "setup_http_mocks", + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks [ ([ MockAPIResponse( @@ -89,6 +89,7 @@ class UserProfile(MockAPIResponse): # in your test file import mocks +import requests # or httpx, aiohttp def test_user_profile(setup_http_mocks): # The mock is already set up by the fixture @@ -97,7 +98,7 @@ def test_user_profile(setup_http_mocks): # To override defaults for a specific test: @pytest.mark.parametrize( - "setup_http_mocks", + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks [([ # Simulate a not found error mocks.UserProfile(status_code=404, json={"error": "User not found"}) @@ -109,16 +110,16 @@ def test_user_not_found(setup_http_mocks): assert response.status_code == 404 ``` -## Supported Libraries and Usage +## Supported Libraries and Their MockSet Objects -Multi-API Mocker provides dedicated pytest fixtures for each supported HTTP client library. While the setup is similar, the returned "mock set" object differs slightly for each. +Multi-API Mocker provides dedicated pytest fixtures for each supported HTTP client library. While the setup using `MockAPIResponse` is consistent, the returned "mock set" object and its capabilities differ based on how the underlying mocking library operates. -### For `requests` +### For `requests` (`requests-mock`) - **Fixture:** `setup_http_mocks` - **Returned Object:** `RequestsMockSet` -This fixture integrates with the `requests-mock` library. +This fixture integrates with the `requests-mock` library. `requests-mock` intercepts requests and provides mock responses based on registered URIs. The `RequestsMockSet` allows you to access the underlying `requests-mock` `_Matcher` objects, which can be used to inspect details of calls *after* they have been made (e.g., `call_count`). **Example:** ```python @@ -140,12 +141,12 @@ def test_requests_example(setup_http_mocks): assert matcher.call_count == 1 ``` -### For `httpx` +### For `httpx` (`pytest-httpx`) - **Fixture:** `setup_httpx_mocks` - **Returned Object:** `HTTPXMockSet` -This fixture integrates with `pytest-httpx`. A key difference from `requests-mock` is that `httpx` requests are created just-in-time when they are executed. The `HTTPXMockSet` helps you inspect these requests *after* they have been made. +This fixture integrates with `pytest-httpx`. A key difference from `requests-mock` is that `httpx` requests are created just-in-time when they are executed. The `HTTPXMockSet` is designed to help you inspect these requests *after* they have been made, as the request objects are not available until the actual HTTP call occurs. **Example:** ```python @@ -169,12 +170,12 @@ def test_httpx_example(setup_httpx_mocks): assert request.method == "GET" ``` -### For `aiohttp` +### For `aiohttp` (`aioresponses`) - **Fixture:** `setup_aiohttp_mocks` - **Returned Object:** `AIOHTTPMockSet` -This fixture integrates with `aioresponses`. Similar to `httpx`, `aiohttp` requests are asynchronous and handled just-in-time. +This fixture integrates with `aioresponses`. Similar to `httpx`, `aiohttp` requests are asynchronous and handled just-in-time. The `AIOHTTPMockSet` provides a similar interface to `HTTPXMockSet` for inspecting requests *after* they have been executed. **Example:** ```python @@ -200,14 +201,14 @@ async def test_aiohttp_example(setup_aiohttp_mocks): ### Simulating Exceptions -You can simulate network errors or other exceptions by passing an `exc` argument. +You can simulate network errors or other exceptions by passing an `exc` argument to your `MockAPIResponse`. ```python import requests from requests.exceptions import ConnectTimeout @pytest.mark.parametrize( - "setup_http_mocks", + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks [([ mocks.UserProfile(exc=ConnectTimeout("Connection timed out")) ])], @@ -220,11 +221,11 @@ def test_with_exception(setup_http_mocks): ### Partial JSON Updates -For minor variations in a response, use `partial_json` to update only specific fields of the `default_json`. +For minor variations in a response, use `partial_json` to update only specific fields of the `default_json` defined in your `MockAPIResponse` (or its subclass). ```python @pytest.mark.parametrize( - "setup_http_mocks", + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks [([ mocks.UserProfile(partial_json={"name": "John Smith"}) ])], @@ -239,4 +240,4 @@ def test_with_partial_json(setup_http_mocks): ## Deprecation Warnings -The `setup_api_mocks` fixture is deprecated and will be removed in a future release. Please use `setup_http_mocks` for `requests` mocking instead. \ No newline at end of file +The `setup_api_mocks` fixture is deprecated and will be removed in a future release. Please use `setup_http_mocks` for `requests` mocking instead. From efe176c7a9af0561d22b4d27aa2f6367902cbd2d Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:21:30 +0300 Subject: [PATCH 04/17] Fix: Address black formatting issues and conditionally load aioresponses plugin --- multi_api_mocker/contrib/pytest_plugin.py | 22 ++++---- multi_api_mocker/http_utils.py | 2 +- requirements_dev.txt | 3 +- tests/test_pytest/conftest.py | 10 ++-- tests/test_pytest/test_fixtures.py | 64 ++++++++++------------- 5 files changed, 52 insertions(+), 49 deletions(-) diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index 8261a0c..b107cb6 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -23,8 +23,9 @@ httpx_available = False try: - from aioresponses import aioresponses # type: ignore # noqa: F401 - from ..aiohttp_utils import AIOHTTPMockSet # noqa: F401 + from aioresponses import aioresponses # type: ignore # noqa: F401 + from ..aiohttp_utils import AIOHTTPMockSet # noqa: F401 + aiohttp_available = True except ImportError: aiohttp_available = False @@ -124,9 +125,9 @@ def setup_httpx_mocks(httpx_mock: HTTPXMock, request) -> HTTPXMockSet: Usage in tests is similar to the original setup_api_mocks, using pytest's parametrize decorator to supply mock response definitions. """ - mock_definitions: List[ - Union[MockAPIResponse, List[MockAPIResponse]] - ] = request.param + mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( + request.param + ) for mock_definition in mock_definitions: if isinstance(mock_definition, list): @@ -156,6 +157,7 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): status_code=mock_definition.status_code, ) + if aiohttp_available: @pytest.fixture @@ -165,9 +167,9 @@ def aiohttp_mock_session(): @pytest.fixture def setup_aiohttp_mocks(aiohttp_mock_session, request) -> AIOHTTPMockSet: - mock_definitions: List[ - Union[MockAPIResponse, List[MockAPIResponse]] - ] = request.param + mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( + request.param + ) for mock_definition in mock_definitions: if isinstance(mock_definition, list): @@ -178,7 +180,9 @@ def setup_aiohttp_mocks(aiohttp_mock_session, request) -> AIOHTTPMockSet: yield AIOHTTPMockSet(mock_definitions, aiohttp_mock_session) - def add_aiohttp_response(aiohttp_mock: aioresponses, mock_definition: MockAPIResponse): + def add_aiohttp_response( + aiohttp_mock: aioresponses, mock_definition: MockAPIResponse + ): if not isinstance(mock_definition, MockAPIResponse): raise ValueError( f"Unsupported mock definition type: {type(mock_definition)}" diff --git a/multi_api_mocker/http_utils.py b/multi_api_mocker/http_utils.py index 3218669..5dc8010 100644 --- a/multi_api_mocker/http_utils.py +++ b/multi_api_mocker/http_utils.py @@ -72,7 +72,7 @@ def get_matcher(self, endpoint_name: str) -> _Matcher: def group_by_url( - api_mocks: List[Union[MockAPIResponse, List[MockAPIResponse]]] + api_mocks: List[Union[MockAPIResponse, List[MockAPIResponse]]], ) -> List[MockConfiguration]: """ Organizes a list of MockAPIResponse objects by their URL and method, grouping diff --git a/requirements_dev.txt b/requirements_dev.txt index 5cdb782..22f6e8b 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -12,5 +12,6 @@ pytest_httpx==0.30.0; python_version >= '3.9' requests_mock==1.11.0 -pip>=23.3 +aioresponses==0.22.0 +pip>=23.3 \ No newline at end of file diff --git a/tests/test_pytest/conftest.py b/tests/test_pytest/conftest.py index 218d9d9..f9e3180 100644 --- a/tests/test_pytest/conftest.py +++ b/tests/test_pytest/conftest.py @@ -9,8 +9,12 @@ pass try: - from multi_api_mocker.contrib.pytest_plugin import setup_aiohttp_mocks # noqa: F401 + from multi_api_mocker.contrib.pytest_plugin import ( + setup_aiohttp_mocks, + aiohttp_available, + ) # noqa: F401 except ImportError: - pass + aiohttp_available = False -pytest_plugins = "aioresponses" +if aiohttp_available: + pytest_plugins = "aioresponses" diff --git a/tests/test_pytest/test_fixtures.py b/tests/test_pytest/test_fixtures.py index ee872c8..f71d760 100644 --- a/tests/test_pytest/test_fixtures.py +++ b/tests/test_pytest/test_fixtures.py @@ -10,20 +10,18 @@ @pytest.mark.parametrize( "setup_api_mocks", [ - ( - [ - MockAPIResponse( - url="https://example.com/api/commit", - method="POST", - json={"message": "Commit successful", "commit_id": "abc123"}, - ), - MockAPIResponse( - url="https://example.com/api/push", - method="POST", - json={"message": "Push successful", "push_id": "xyz456"}, - ), - ] - ) + [ + MockAPIResponse( + url="https://example.com/api/commit", + method="POST", + json={"message": "Commit successful", "commit_id": "abc123"}, + ), + MockAPIResponse( + url="https://example.com/api/push", + method="POST", + json={"message": "Push successful", "push_id": "xyz456"}, + ), + ] ], indirect=True, ) @@ -43,20 +41,18 @@ def test_commit_and_push(setup_api_mocks): @pytest.mark.parametrize( "setup_http_mocks", [ - ( - [ - MockAPIResponse( - url="https://example.com/api/commit", - method="POST", - json={"message": "Commit successful", "commit_id": "abc123"}, - ), - MockAPIResponse( - url="https://example.com/api/push", - method="POST", - json={"message": "Push successful", "push_id": "xyz456"}, - ), - ] - ) + [ + MockAPIResponse( + url="https://example.com/api/commit", + method="POST", + json={"message": "Commit successful", "commit_id": "abc123"}, + ), + MockAPIResponse( + url="https://example.com/api/push", + method="POST", + json={"message": "Push successful", "push_id": "xyz456"}, + ), + ] ], indirect=True, ) @@ -76,13 +72,11 @@ def test_commit_and_push_with_updated_http_mock(setup_http_mocks): @pytest.mark.parametrize( "setup_api_mocks", [ - ( - [ - mocks.Fork(), - mocks.Commit(), - mocks.Push(), - ] - ) + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(), + ] ], indirect=True, ) From 5114a82a554733a0f547139047f45b040390d82d Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:23:19 +0300 Subject: [PATCH 05/17] feat: Add aiohttp exception test case --- tests/test_pytest/test_aiohttp_fixtures.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py index 6aa9bdc..c262678 100644 --- a/tests/test_pytest/test_aiohttp_fixtures.py +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -1,5 +1,5 @@ import pytest -from aiohttp import ClientSession +from aiohttp import ClientSession, client_exceptions from multi_api_mocker.definitions import MockAPIResponse @@ -23,3 +23,23 @@ async def test_aiohttp_mocking(setup_aiohttp_mocks): async with session.get("https://example.com/api/test") as response: assert response.status == 200 assert await response.json() == {"message": "Success"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/error", + method="GET", + exc=client_exceptions.ClientConnectorError(None, None), + ) + ] + ], + indirect=True, +) +async def test_aiohttp_exception_mocking(setup_aiohttp_mocks): + async with ClientSession() as session: + with pytest.raises(client_exceptions.ClientConnectorError): + await session.get("https://example.com/api/error") From 1fca50602b2f72f56cce8dd9bc121961f6392482 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:25:01 +0300 Subject: [PATCH 06/17] Fix: Update aioresponses version in requirements_dev.txt for Python 3.10 compatibility --- requirements_dev.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 22f6e8b..7f061af 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -12,6 +12,7 @@ pytest_httpx==0.30.0; python_version >= '3.9' requests_mock==1.11.0 -aioresponses==0.22.0 +aioresponses==0.7.8 + +pip>=23.3 -pip>=23.3 \ No newline at end of file From c4480040b5cef2dc946f2a31ea772b56a4d52ca9 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:38:04 +0300 Subject: [PATCH 07/17] Fix: Update black version in requirements_dev.txt to 25.1.0 for GitHub Actions compatibility --- requirements_dev.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index 7f061af..68e8204 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -5,7 +5,7 @@ coverage==7.3.2 twine==4.0.2 pytest==7.4.3 -black==23.11.0 +black==25.1.0 pytest_httpx==0.22.0; python_version < '3.9' pytest_httpx==0.30.0; python_version >= '3.9' @@ -14,5 +14,4 @@ requests_mock==1.11.0 aioresponses==0.7.8 -pip>=23.3 - +pip>=23.3 \ No newline at end of file From fbb655c47cfee1c589a2311b9d87570fa0766e9b Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:40:27 +0300 Subject: [PATCH 08/17] Fix: Resolve flake8 F401 unused import in conftest.py --- tests/test_pytest/conftest.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_pytest/conftest.py b/tests/test_pytest/conftest.py index f9e3180..0dfb5d4 100644 --- a/tests/test_pytest/conftest.py +++ b/tests/test_pytest/conftest.py @@ -9,10 +9,8 @@ pass try: - from multi_api_mocker.contrib.pytest_plugin import ( - setup_aiohttp_mocks, - aiohttp_available, - ) # noqa: F401 + from multi_api_mocker.contrib.pytest_plugin import setup_aiohttp_mocks # noqa: F401 + from multi_api_mocker.contrib.pytest_plugin import aiohttp_available except ImportError: aiohttp_available = False From 8a7d7609b49059f464bec5c95d879b7d3c07b5f3 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:46:11 +0300 Subject: [PATCH 09/17] feat: Move pytest_plugins to root conftest.py and fix aioresponses exception handling --- conftest.py | 4 ++++ multi_api_mocker/contrib/pytest_plugin.py | 2 +- tests/test_pytest/conftest.py | 3 --- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 conftest.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..2ac378a --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +from multi_api_mocker.contrib.pytest_plugin import aiohttp_available + +if aiohttp_available: + pytest_plugins = "aioresponses" diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index b107cb6..011bebd 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -188,7 +188,7 @@ def add_aiohttp_response( f"Unsupported mock definition type: {type(mock_definition)}" ) if mock_definition.exc: - aiohttp_mock.exception( + aiohttp_mock.add( url=mock_definition.url, method=mock_definition.method.upper(), exception=mock_definition.exc, diff --git a/tests/test_pytest/conftest.py b/tests/test_pytest/conftest.py index 0dfb5d4..ad5f8e4 100644 --- a/tests/test_pytest/conftest.py +++ b/tests/test_pytest/conftest.py @@ -13,6 +13,3 @@ from multi_api_mocker.contrib.pytest_plugin import aiohttp_available except ImportError: aiohttp_available = False - -if aiohttp_available: - pytest_plugins = "aioresponses" From 0f6a5ad6a39df0da54ea287be01e20ec66ab7589 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:47:28 +0300 Subject: [PATCH 10/17] Fix: Correctly pass OSError to ClientConnectorError in aiohttp exception test --- tests/test_pytest/test_aiohttp_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py index c262678..6aa5301 100644 --- a/tests/test_pytest/test_aiohttp_fixtures.py +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -33,7 +33,7 @@ async def test_aiohttp_mocking(setup_aiohttp_mocks): MockAPIResponse( url="https://example.com/api/error", method="GET", - exc=client_exceptions.ClientConnectorError(None, None), + exc=OSError("Connection refused"), ) ] ], From d2b7a991fd7357769c748998d8038fe4aeaf1a23 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 00:48:18 +0300 Subject: [PATCH 11/17] Fix: Update aiohttp exception test to assert OSError --- tests/test_pytest/test_aiohttp_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py index 6aa5301..8331f14 100644 --- a/tests/test_pytest/test_aiohttp_fixtures.py +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -41,5 +41,5 @@ async def test_aiohttp_mocking(setup_aiohttp_mocks): ) async def test_aiohttp_exception_mocking(setup_aiohttp_mocks): async with ClientSession() as session: - with pytest.raises(client_exceptions.ClientConnectorError): + with pytest.raises(OSError): await session.get("https://example.com/api/error") From b00640beb96cf7620847bd3bef8c54911c70c6b0 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 10:21:22 +0300 Subject: [PATCH 12/17] fix(ci): remove unused import in aiohttp test --- tests/test_pytest/test_aiohttp_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py index 8331f14..bfca237 100644 --- a/tests/test_pytest/test_aiohttp_fixtures.py +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -1,5 +1,5 @@ import pytest -from aiohttp import ClientSession, client_exceptions +from aiohttp import ClientSession from multi_api_mocker.definitions import MockAPIResponse From c5c0345a9cb3241c37c6487eb44358fcfa87e706 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 11:00:40 +0300 Subject: [PATCH 13/17] fix(tests): use setup_http_mocks instead of deprecated setup_api_mocks --- tests/test_pytest/test_fixtures.py | 71 +++++++++--------------------- 1 file changed, 20 insertions(+), 51 deletions(-) diff --git a/tests/test_pytest/test_fixtures.py b/tests/test_pytest/test_fixtures.py index f71d760..7efac3e 100644 --- a/tests/test_pytest/test_fixtures.py +++ b/tests/test_pytest/test_fixtures.py @@ -7,37 +7,6 @@ from . import mocks -@pytest.mark.parametrize( - "setup_api_mocks", - [ - [ - MockAPIResponse( - url="https://example.com/api/commit", - method="POST", - json={"message": "Commit successful", "commit_id": "abc123"}, - ), - MockAPIResponse( - url="https://example.com/api/push", - method="POST", - json={"message": "Push successful", "push_id": "xyz456"}, - ), - ] - ], - indirect=True, -) -def test_commit_and_push(setup_api_mocks): - # Perform the commit API call - commit_response = requests.post("https://example.com/api/commit") - assert commit_response.json() == { - "message": "Commit successful", - "commit_id": "abc123", - } - - # Perform the push API call - push_response = requests.post("https://example.com/api/push") - assert push_response.json() == {"message": "Push successful", "push_id": "xyz456"} - - @pytest.mark.parametrize( "setup_http_mocks", [ @@ -56,7 +25,7 @@ def test_commit_and_push(setup_api_mocks): ], indirect=True, ) -def test_commit_and_push_with_updated_http_mock(setup_http_mocks): +def test_commit_and_push(setup_http_mocks): # Perform the commit API call commit_response = requests.post("https://example.com/api/commit") assert commit_response.json() == { @@ -70,7 +39,7 @@ def test_commit_and_push_with_updated_http_mock(setup_http_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ [ mocks.Fork(), @@ -80,8 +49,8 @@ def test_commit_and_push_with_updated_http_mock(setup_http_mocks): ], indirect=True, ) -def test_single_flow_multiple_api_calls(setup_api_mocks): - mock_set = setup_api_mocks +def test_single_flow_multiple_api_calls(setup_http_mocks): + mock_set = setup_http_mocks # Perform the API call response = requests.post("https://example.com/api/fork") @@ -96,7 +65,7 @@ def test_single_flow_multiple_api_calls(setup_api_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ # Scenario 1: Push fails with a 400 error ( @@ -118,8 +87,8 @@ def test_single_flow_multiple_api_calls(setup_api_mocks): ], indirect=True, ) -def test_multiple_scenarios(setup_api_mocks): - mock_set = setup_api_mocks +def test_multiple_scenarios(setup_http_mocks): + mock_set = setup_http_mocks # Perform the API call response = requests.post("https://example.com/api/fork") @@ -139,7 +108,7 @@ def test_multiple_scenarios(setup_api_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ # Scenario 1: Push fails with a 400 error ( @@ -160,8 +129,8 @@ def test_multiple_scenarios(setup_api_mocks): ], indirect=True, ) -def test_exception(setup_api_mocks): - mock_set = setup_api_mocks +def test_exception(setup_http_mocks): + mock_set = setup_http_mocks # Perform the API call response = requests.post("https://example.com/api/fork") @@ -176,14 +145,14 @@ def test_exception(setup_api_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ ([mocks.Fork(), mocks.Commit(), mocks.Push(partial_json={"id": "partial_id"})]), ], indirect=True, ) -def test_partial_json(setup_api_mocks): - mock_set = setup_api_mocks +def test_partial_json(setup_http_mocks): + mock_set = setup_http_mocks response = requests.post("https://example.com/api/push") expected_json = mock_set["Push"].json @@ -194,7 +163,7 @@ def test_partial_json(setup_api_mocks): @pytest.mark.parametrize( - "user_email, setup_api_mocks", + "user_email, setup_http_mocks", [ ("dev1@example.com", [mocks.Fork(), mocks.Commit(), mocks.Push()]), ( @@ -206,10 +175,10 @@ def test_partial_json(setup_api_mocks): ], ), ], - indirect=["setup_api_mocks"], + indirect=["setup_http_mocks"], ) -def test_flexible_parametrization(user_email, setup_api_mocks): - mock_set = setup_api_mocks +def test_flexible_parametrization(user_email, setup_http_mocks): + mock_set = setup_http_mocks response = requests.post("https://example.com/api/push", json={"email": user_email}) expected_json = mock_set["Push"].json @@ -219,7 +188,7 @@ def test_flexible_parametrization(user_email, setup_api_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ ( [ @@ -230,8 +199,8 @@ def test_flexible_parametrization(user_email, setup_api_mocks): ], indirect=True, ) -def test_same_endpoint_url(setup_api_mocks): - mock_set = setup_api_mocks +def test_same_endpoint_url(setup_http_mocks): + mock_set = setup_http_mocks response = requests.post("https://example.com/api/push") assert response.json() == mock_set["Push"].json From e41139b540d7d27a85d6e8f4c2d15871ce2fce58 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 11:02:56 +0300 Subject: [PATCH 14/17] fix(ci): correct scope for aiohttp_mock_session fixture --- multi_api_mocker/contrib/pytest_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index 011bebd..a818935 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -160,7 +160,7 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): if aiohttp_available: - @pytest.fixture + @pytest.fixture(scope="function") def aiohttp_mock_session(): with aioresponses() as m: yield m From 2d1f82f1bf7a658ad8ca31c0a76ea4cf5c675e11 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 11:05:51 +0300 Subject: [PATCH 15/17] fix(ci): refactor aiohttp mock fixture --- multi_api_mocker/contrib/pytest_plugin.py | 28 ++++++++++------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index a818935..f2f711a 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -160,25 +160,21 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): if aiohttp_available: - @pytest.fixture(scope="function") - def aiohttp_mock_session(): - with aioresponses() as m: - yield m - @pytest.fixture - def setup_aiohttp_mocks(aiohttp_mock_session, request) -> AIOHTTPMockSet: - mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( - request.param - ) + def setup_aiohttp_mocks(request) -> AIOHTTPMockSet: + with aioresponses() as m: + mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( + request.param + ) - for mock_definition in mock_definitions: - if isinstance(mock_definition, list): - for nested_mock_definition in mock_definition: - add_aiohttp_response(aiohttp_mock_session, nested_mock_definition) - else: - add_aiohttp_response(aiohttp_mock_session, mock_definition) + for mock_definition in mock_definitions: + if isinstance(mock_definition, list): + for nested_mock_definition in mock_definition: + add_aiohttp_response(m, nested_mock_definition) + else: + add_aiohttp_response(m, mock_definition) - yield AIOHTTPMockSet(mock_definitions, aiohttp_mock_session) + yield AIOHTTPMockSet(mock_definitions, m) def add_aiohttp_response( aiohttp_mock: aioresponses, mock_definition: MockAPIResponse From 6a635e39b46ae64b3dbb2375a609e0fc44d0b733 Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 12:36:41 +0300 Subject: [PATCH 16/17] Refactor: Add MockAIOAPIResponse for advanced aiohttp features Introduced MockAIOAPIResponse, a subclass of MockAPIResponse, to support advanced aioresponses features like custom headers, raw body content, and dynamic callbacks. This change enhances the library's capabilities for mocking aiohttp requests without altering the unified MockAPIResponse interface, ensuring backward compatibility. Key changes: - Created MockAIOAPIResponse in multi_api_mocker/aiohttp_utils.py to encapsulate advanced aiohttp mocking features. - Updated the pytest plugin in multi_api_mocker/contrib/pytest_plugin.py to recognize and utilize MockAIOAPIResponse. - Ensured that the new class is a drop-in replacement for MockAPIResponse and that existing tests pass without modification. --- multi_api_mocker/aiohttp_utils.py | 64 ++++++++++++++++++++++- multi_api_mocker/contrib/pytest_plugin.py | 17 +++++- multi_api_mocker/httpx_utils.py | 14 +++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/multi_api_mocker/aiohttp_utils.py b/multi_api_mocker/aiohttp_utils.py index bf728cd..4c93b3d 100644 --- a/multi_api_mocker/aiohttp_utils.py +++ b/multi_api_mocker/aiohttp_utils.py @@ -1,8 +1,68 @@ -from typing import List +from typing import List, Callable from aioresponses import aioresponses from multi_api_mocker.definitions import MockAPIResponse +class MockAIOAPIResponse(MockAPIResponse): + """ + A specialized MockAPIResponse for aiohttp with advanced features. + + This class extends MockAPIResponse to include support for aioresponses-specific + features such as custom headers, raw body content, and dynamic callbacks. It ensures + that advanced features are only used with this specialized class, raising a + TypeError if they are attempted with the base MockAPIResponse. + + Attributes: + default_headers (dict): Default headers for the response. + default_body (bytes): Default body for the response. + default_callback (Callable): Default callback for the response. + """ + + default_headers: dict | None = None + default_body: bytes | None = None + default_callback: Callable | None = None + + def __init__( + self, + *args, + headers=None, + body=None, + callback=None, + **kwargs, + ): + """ + Initializes the MockAIOAPIResponse with advanced options. + + Args: + headers (dict, optional): The headers of the response. + body (bytes, optional): The body of the response. + callback (Callable, optional): The callback to execute. + **kwargs: Additional keyword arguments for the base class. + """ + super().__init__(*args, **kwargs) + self._headers = headers + self._body = body + self._callback = callback + + if (headers or body or callback) and not isinstance(self, MockAIOAPIResponse): + raise TypeError( + "Advanced features like headers, body, and callback are only " + "available with MockAIOAPIResponse." + ) + + @property + def headers(self): + return self._headers or self.__class__.default_headers + + @property + def body(self): + return self._body or self.__class__.default_body + + @property + def callback(self): + return self._callback or self.__class__.default_callback + + class AIOHTTPMockSet: """ A collection class that manages MockAPIResponse objects and integrates with the @@ -32,4 +92,4 @@ def __len__(self): def __repr__(self): endpoint_names = ", ".join(self._response_registry.keys()) - return f"<{self.__class__.__name__} with endpoints: {endpoint_names}>" + return f"<{self.__class__.__name__} with endpoints: {endpoint_names}>" \ No newline at end of file diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index f2f711a..12c0b9d 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -27,6 +27,7 @@ from ..aiohttp_utils import AIOHTTPMockSet # noqa: F401 aiohttp_available = True + from ..aiohttp_utils import MockAIOAPIResponse except ImportError: aiohttp_available = False @@ -183,6 +184,7 @@ def add_aiohttp_response( raise ValueError( f"Unsupported mock definition type: {type(mock_definition)}" ) + if mock_definition.exc: aiohttp_mock.add( url=mock_definition.url, @@ -190,9 +192,22 @@ def add_aiohttp_response( exception=mock_definition.exc, ) else: + payload = mock_definition.json + headers = None + body = None + callback = None + + if isinstance(mock_definition, MockAIOAPIResponse): + headers = mock_definition.headers + body = mock_definition.body + callback = mock_definition.callback + aiohttp_mock.add( url=mock_definition.url, method=mock_definition.method.upper(), - payload=mock_definition.json, + payload=payload, status=mock_definition.status_code, + headers=headers, + body=body, + callback=callback, ) diff --git a/multi_api_mocker/httpx_utils.py b/multi_api_mocker/httpx_utils.py index 6d2915a..a83f4cd 100644 --- a/multi_api_mocker/httpx_utils.py +++ b/multi_api_mocker/httpx_utils.py @@ -78,3 +78,17 @@ def get_request(self, endpoint_name: str) -> Request: if request.url == self._response_registry[endpoint_name].url: return request raise KeyError(f"No request found for endpoint: {endpoint_name}") + + def get_matcher(self, url: str): + """ + Retrieves the httpx_mock instance. This is a workaround to allow access to + the call_count property, as pytest-httpx does not have a concept of + "matchers" like requests-mock. + + Parameters: + url (str): The URL of the endpoint to retrieve the matcher for. + + Returns: + HTTPXMock: The httpx_mock instance. + """ + return self.httpx_mock From 74c83e9e0935f2513d0cd9fb402e7ad9b001b72b Mon Sep 17 00:00:00 2001 From: Dacian Popute Date: Fri, 27 Jun 2025 16:44:25 +0300 Subject: [PATCH 17/17] feat(mocking): add headers and callback parameters to MockAPIResponse --- CHANGELOG.md | 9 + README.md | 137 ++++++++- conftest.py | 4 - multi_api_mocker/contrib/pytest_plugin.py | 116 +++----- multi_api_mocker/definitions.py | 22 +- multi_api_mocker/http_utils.py | 1 + multi_api_mocker/httpx_utils.py | 14 + multi_api_mocker/models.py | 1 + requirements_dev.txt | 27 +- setup.cfg | 3 +- tests/contrib/test_pytest_plugin.py | 135 +++++++++ tests/test_aiohttp_utils.py | 19 ++ tests/test_definitions.py | 79 +++++ tests/test_http_utils.py | 100 +++++++ tests/test_httpx_utils.py | 38 +++ tests/test_models.py | 40 +++ tests/test_pytest/test_aiohttp_fixtures.py | 85 ++++++ tests/test_pytest/test_fixtures.py | 16 ++ tests/test_pytest/test_http_fixtures.py | 261 +++++++++++++++++ tests/test_pytest/test_httpx_fixtures.py | 320 +++++++++++++++++++++ 20 files changed, 1330 insertions(+), 97 deletions(-) create mode 100644 tests/contrib/test_pytest_plugin.py create mode 100644 tests/test_aiohttp_utils.py create mode 100644 tests/test_http_utils.py create mode 100644 tests/test_httpx_utils.py create mode 100644 tests/test_models.py create mode 100644 tests/test_pytest/test_http_fixtures.py create mode 100644 tests/test_pytest/test_httpx_fixtures.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9211e9e..132aaf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.4.0] - 2024-06-27 + +### Added +- Added `headers` and `callback` parameters to `MockAPIResponse` for advanced mocking scenarios. +- The `headers` parameter is available for all supported libraries. +- The `callback` parameter is available for `aiohttp`, `requests` and `httpx`. + +### Changed +- Removed `MockAIOAPIResponse` in favor of using `MockAPIResponse` for all `aiohttp` mocking. ## [1.3.0] - 2024-02-05 diff --git a/README.md b/README.md index aac7bab..9ae4ed5 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Multi-API Mocker provides dedicated pytest fixtures for each supported HTTP clie - **Fixture:** `setup_http_mocks` - **Returned Object:** `RequestsMockSet` -This fixture integrates with the `requests-mock` library. `requests-mock` intercepts requests and provides mock responses based on registered URIs. The `RequestsMockSet` allows you to access the underlying `requests-mock` `_Matcher` objects, which can be used to inspect details of calls *after* they have been made (e.g., `call_count`). +This fixture integrates with the `requests-mock` library. `requests-mock` intercepts requests and provides mock responses based on registered URIs. The `RequestsMockSet` allows you to access the underlying `requests-mock` `_Matcher` objects via the `get_matcher()` method, which can be used to inspect details of calls *after* they have been made (e.g., `call_count`). **Example:** ```python @@ -146,7 +146,7 @@ def test_requests_example(setup_http_mocks): - **Fixture:** `setup_httpx_mocks` - **Returned Object:** `HTTPXMockSet` -This fixture integrates with `pytest-httpx`. A key difference from `requests-mock` is that `httpx` requests are created just-in-time when they are executed. The `HTTPXMockSet` is designed to help you inspect these requests *after* they have been made, as the request objects are not available until the actual HTTP call occurs. +This fixture integrates with `pytest-httpx`. A key difference from `requests-mock` is that `httpx` requests are created just-in-time when they are executed. The `HTTPXMockSet` is designed to help you inspect these requests *after* they have been made via the `get_request()` method, as the request objects are not available until the actual HTTP call occurs. **Example:** ```python @@ -175,7 +175,7 @@ def test_httpx_example(setup_httpx_mocks): - **Fixture:** `setup_aiohttp_mocks` - **Returned Object:** `AIOHTTPMockSet` -This fixture integrates with `aioresponses`. Similar to `httpx`, `aiohttp` requests are asynchronous and handled just-in-time. The `AIOHTTPMockSet` provides a similar interface to `HTTPXMockSet` for inspecting requests *after* they have been executed. +This fixture integrates with `aioresponses`. Similar to `httpx`, `aiohttp` requests are asynchronous and handled just-in-time. The `AIOHTTPMockSet` provides a `get_request()` method for inspecting requests *after* they have been executed. **Example:** ```python @@ -238,6 +238,137 @@ def test_with_partial_json(setup_http_mocks): assert response.json()["id"] == "user123" ``` +### Advanced Mocking with `MockAPIResponse` + +For advanced mocking scenarios, you can use the `MockAPIResponse` class. This class provides additional parameters that map directly to the capabilities of the underlying mocking libraries, giving you more control over the mocked response. + +- **`headers`**: A dictionary of response headers. +- **`callback`**: A function that will be called to generate a dynamic response. + +#### `aiohttp` + +The callback function will receive the URL of the request and any other keyword arguments, and it should return an `aioresponses.CallbackResult` object. + +**Example:** +```python +import pytest +from aiohttp import ClientSession +from multi_api_mocker.definitions import MockAPIResponse +from aioresponses import CallbackResult + +# Example of a callback function +def dynamic_callback(url, **kwargs): + # You can add custom logic here to determine the response + if "error" in kwargs["params"]: + return CallbackResult(status=500, body="Internal Server Error") + return CallbackResult(status=200, body="Success") + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + # Mocking with custom headers + MockAPIResponse( + url="https://example.com/api/test_headers", + method="GET", + json={"message": "Success"}, + status_code=200, + headers={"X-Custom-Header": "Test-Value"}, + ), + # Mocking with a dynamic callback + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=dynamic_callback, + ), + ] + ], + indirect=True, +) +async def test_advanced_aiohttp_mocking(setup_aiohttp_mocks): + async with ClientSession() as session: + # Test custom headers + async with session.get("https://example.com/api/test_headers") as response: + assert response.status == 200 + assert await response.json() == {"message": "Success"} + assert response.headers["X-Custom-Header"] == "Test-Value" + + # Test dynamic callback + async with session.get("https://example.com/api/test_callback") as response: + assert response.status == 200 + assert await response.text() == "Success" +``` + +#### `requests` + +The callback function will receive the request object and the context, and it should return a JSON serializable object. + +**Example:** +```python +import pytest +import requests +from multi_api_mocker.definitions import MockAPIResponse + +# Example of a callback function +def dynamic_callback(request, context): + context.status_code = 200 + return {"message": "Callback executed"} + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=dynamic_callback, + ) + ] + ], + indirect=True, +) +def test_advanced_http_mocking(setup_http_mocks): + response = requests.get("https://example.com/api/test_callback") + assert response.status_code == 200 + assert response.json() == {"message": "Callback executed"} +``` + +#### `httpx` + +The callback function will receive the request object and it should return an `httpx.Response` object. + +**Example:** +```python +import pytest +import httpx +from multi_api_mocker.definitions import MockAPIResponse + +# Example of a callback function +def dynamic_callback(request): + return httpx.Response(200, json={"message": "Callback executed"}) + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=dynamic_callback, + ) + ] + ], + indirect=True, +) +def test_advanced_httpx_mocking(setup_httpx_mocks): + with httpx.Client() as client: + response = client.get("https://example.com/api/test_callback") + assert response.status_code == 200 + assert response.json() == {"message": "Callback executed"} +``` + + ## Deprecation Warnings The `setup_api_mocks` fixture is deprecated and will be removed in a future release. Please use `setup_http_mocks` for `requests` mocking instead. diff --git a/conftest.py b/conftest.py index 2ac378a..e69de29 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +0,0 @@ -from multi_api_mocker.contrib.pytest_plugin import aiohttp_available - -if aiohttp_available: - pytest_plugins = "aioresponses" diff --git a/multi_api_mocker/contrib/pytest_plugin.py b/multi_api_mocker/contrib/pytest_plugin.py index f2f711a..a735c82 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -35,55 +35,15 @@ @pytest.fixture(scope="function") def setup_http_mocks(requests_mock: Mocker, request) -> RequestsMockSet: - """ - A pytest fixture for configuring mock HTTP responses in a test environment. - It takes subclasses of MockAPIResponse, each representing a unique API call - configuration. These subclasses facilitate the creation of simple or complex - response flows, simulating real-world API interactions. - - Parameters: - requests_mock (Mocker): The pytest requests_mock fixture. - request: The pytest request object containing parametrized test data. - - Returns: - RequestsMockSet: An instance of MockSet containing the organized - MockAPIResponse objects, ready for use in tests. - - The fixture supports multiple test scenarios, allowing for thorough - testing of varying API response conditions. This is especially useful - for simulating sequences of API calls like Fork, Commit, and Push - in a version control system context. - - Example Usage: - - Single API Call Test: - @pytest.mark.parametrize("setup_http_mocks", [([Fork()])], indirect=True) - - - Multi-call Sequence Test: - @pytest.mark.parametrize( - "setup_http_mocks", [([Fork(), Commit(), Push()])], indirect=True - ) - - - Testing Multiple Scenarios: - @pytest.mark.parametrize( - "setup_http_mocks", - [([Fork(), Commit(), Push()]), ([Fork(), Commit(), ForcePush()])], - indirect=True - ) - - - This fixture converts the list of MockAPIResponse subclasses into - MockConfiguration instances, registers them with requests_mock, - and returns a MockSet object, which allows querying each mock - by its endpoint name. - """ + if not requests_mock_available: + pytest.skip("requests-mock is not installed") yield from configure_http_mocks(requests_mock, request) # Deprecated wrapper fixture @pytest.fixture(scope="function") def setup_api_mocks(requests_mock: Mocker, request) -> RequestsMockSet: - """ - Deprecated: Use `setup_http_mocks` instead. - """ + if not requests_mock_available: + pytest.skip("requests-mock is not installed") warnings.warn( "`setup_api_mocks` is deprecated and will be removed in a future release. " "Please use `setup_http_mocks` instead.", @@ -97,8 +57,20 @@ def configure_http_mocks(requests_mock: Mocker, request): matchers = {} for api_mock in api_mocks_configurations: + responses = [] + for response in api_mock.responses: + response_data = { + key: response.get(key) + for key in ("json", "status_code", "headers", "exc") + if response.get(key) is not None + } + if response.get("callback") is not None: + response_data["json"] = response.get("callback") + responses.append(response_data) matcher = requests_mock.register_uri( - api_mock.method, api_mock.url, api_mock.responses + api_mock.method, + api_mock.url, + response_list=responses, ) matchers[api_mock.url] = matcher @@ -109,34 +81,22 @@ def configure_http_mocks(requests_mock: Mocker, request): @pytest.fixture(scope="function") def setup_httpx_mocks(httpx_mock: HTTPXMock, request) -> HTTPXMockSet: - """ - A pytest fixture for configuring mock HTTPX responses in a test environment. - Directly registers each mock response for HTTPX, leveraging pytest-httpx's - ability to queue multiple responses for the same URL and method. - - Parameters: - httpx_mock (HTTPXMock): The pytest-httpx fixture for mocking HTTPX requests. - request: The pytest request object containing parameterized test data. - - Returns: - HTTPXMockSet: An instance of HttpxMockSet containing the organized - MockAPIResponse objects, ready for use in tests. - - Usage in tests is similar to the original setup_api_mocks, using pytest's - parametrize decorator to supply mock response definitions. - """ + if not httpx_available: + pytest.skip("pytest-httpx is not installed") mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( request.param ) - + flattened_definitions = [] for mock_definition in mock_definitions: if isinstance(mock_definition, list): - for nested_mock_definition in mock_definition: - add_response(httpx_mock, nested_mock_definition) + flattened_definitions.extend(mock_definition) else: - add_response(httpx_mock, mock_definition) + flattened_definitions.append(mock_definition) + + for mock_definition in flattened_definitions: + add_response(httpx_mock, mock_definition) - yield HTTPXMockSet(mock_definitions, httpx_mock) + yield HTTPXMockSet(flattened_definitions, httpx_mock) def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): if not isinstance(mock_definition, MockAPIResponse): @@ -149,12 +109,20 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): method=mock_definition.method, exception=mock_definition.exc, ) + elif mock_definition.callback: + httpx_mock.add_callback( + url=mock_definition.url, + method=mock_definition.method, + callback=mock_definition.callback, + ) else: httpx_mock.add_response( url=mock_definition.url, method=mock_definition.method, json=mock_definition.json, + text=mock_definition.text, status_code=mock_definition.status_code, + headers=mock_definition.headers, ) @@ -162,19 +130,23 @@ def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): @pytest.fixture def setup_aiohttp_mocks(request) -> AIOHTTPMockSet: + if not aiohttp_available: + pytest.skip("aioresponses is not installed") with aioresponses() as m: mock_definitions: List[Union[MockAPIResponse, List[MockAPIResponse]]] = ( request.param ) - + flattened_definitions = [] for mock_definition in mock_definitions: if isinstance(mock_definition, list): - for nested_mock_definition in mock_definition: - add_aiohttp_response(m, nested_mock_definition) + flattened_definitions.extend(mock_definition) else: - add_aiohttp_response(m, mock_definition) + flattened_definitions.append(mock_definition) + + for mock_definition in flattened_definitions: + add_aiohttp_response(m, mock_definition) - yield AIOHTTPMockSet(mock_definitions, m) + yield AIOHTTPMockSet(flattened_definitions, m) def add_aiohttp_response( aiohttp_mock: aioresponses, mock_definition: MockAPIResponse @@ -195,4 +167,6 @@ def add_aiohttp_response( method=mock_definition.method.upper(), payload=mock_definition.json, status=mock_definition.status_code, + headers=mock_definition.headers, + callback=mock_definition.callback, ) diff --git a/multi_api_mocker/definitions.py b/multi_api_mocker/definitions.py index 13b9860..51bf4f5 100644 --- a/multi_api_mocker/definitions.py +++ b/multi_api_mocker/definitions.py @@ -1,6 +1,6 @@ import inspect import re -from typing import Any +from typing import Any, Callable class MockAPIResponse: @@ -48,6 +48,8 @@ class MockAPIResponse: default_json: Any | None = None default_text: str | None = None default_exc: Exception | type[Exception] | None = None + default_headers: dict | None = None + default_callback: Callable | None = None def __init__( self, @@ -59,6 +61,8 @@ def __init__( text=None, endpoint_name=None, exc=None, + headers=None, + callback=None, **kwargs, ): """ @@ -82,6 +86,10 @@ def __init__( the class name. exc (Union[Exception, Type[Exception], None], optional): Exception to raise for the request. Defaults to None. + headers (dict, optional): The headers of the response. Defaults to the + class-level `headers` attribute. + callback (Callable, optional): The callback to execute when the request is + made. This is only supported for aiohttp. **kwargs: Additional keyword arguments for customizing the response. """ @@ -95,6 +103,8 @@ def __init__( self._json = json self._text = text self._exc = exc + self._headers = headers + self._callback = callback self.kwargs = kwargs def __repr__(self): @@ -115,6 +125,8 @@ def validate_class_attributes(cls): "endpoint_name": (str,), "default_status_code": (int, type(None)), "default_text": (str, type(None)), + "default_headers": (dict, type(None)), + "default_callback": (Callable, type(None)), } for attr, expected_types in expected_class_attribute_types.items(): @@ -173,6 +185,14 @@ def text(self): def exc(self): return self._exc or self.__class__.default_exc + @property + def headers(self): + return self._headers or self.__class__.default_headers + + @property + def callback(self): + return self._callback or self.__class__.default_callback + def _default_json(self, status_code): return self.default_json.copy() if self.default_json else None diff --git a/multi_api_mocker/http_utils.py b/multi_api_mocker/http_utils.py index 5dc8010..c0f6609 100644 --- a/multi_api_mocker/http_utils.py +++ b/multi_api_mocker/http_utils.py @@ -127,5 +127,6 @@ def add_mock_to_group(grouped_mocks, mock): status_code=mock.status_code if not mock.exc else None, json=mock.json if not mock.exc else None, exc=mock.exc if mock.exc else None, + headers=mock.headers if not mock.exc else None, ) grouped_mocks[(mock.url, mock.method)].append(response_kwargs) diff --git a/multi_api_mocker/httpx_utils.py b/multi_api_mocker/httpx_utils.py index 6d2915a..a83f4cd 100644 --- a/multi_api_mocker/httpx_utils.py +++ b/multi_api_mocker/httpx_utils.py @@ -78,3 +78,17 @@ def get_request(self, endpoint_name: str) -> Request: if request.url == self._response_registry[endpoint_name].url: return request raise KeyError(f"No request found for endpoint: {endpoint_name}") + + def get_matcher(self, url: str): + """ + Retrieves the httpx_mock instance. This is a workaround to allow access to + the call_count property, as pytest-httpx does not have a concept of + "matchers" like requests-mock. + + Parameters: + url (str): The URL of the endpoint to retrieve the matcher for. + + Returns: + HTTPXMock: The httpx_mock instance. + """ + return self.httpx_mock diff --git a/multi_api_mocker/models.py b/multi_api_mocker/models.py index 32895ad..122b0e0 100644 --- a/multi_api_mocker/models.py +++ b/multi_api_mocker/models.py @@ -16,6 +16,7 @@ class ResponseKwargs: status_code: Optional[int] = None json: Optional[Any] = None exc: Optional[Exception] = None + headers: Optional[Dict[str, str]] = None def to_dict(self) -> Dict[str, Any]: """ diff --git a/requirements_dev.txt b/requirements_dev.txt index 68e8204..d6ecf60 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,17 +1,12 @@ -bump2version==1.0.1 -wheel==0.41.3 -flake8==3.7.8 -coverage==7.3.2 -twine==4.0.2 - -pytest==7.4.3 -black==25.1.0 - -pytest_httpx==0.22.0; python_version < '3.9' -pytest_httpx==0.30.0; python_version >= '3.9' - -requests_mock==1.11.0 - +-e . +black +isort +flake8 +mypy +pytest +pytest-cov +pytest-asyncio +requests-mock +httpx +aiohttp aioresponses==0.7.8 - -pip>=23.3 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 5d1c1b7..0bff895 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,8 +23,7 @@ max-line-length = 88 addopts = --ignore=setup.py [coverage:run] -omit = - tests/* +source = multi_api_mocker [options.entry_points] pytest11 = diff --git a/tests/contrib/test_pytest_plugin.py b/tests/contrib/test_pytest_plugin.py new file mode 100644 index 0000000..f697d9a --- /dev/null +++ b/tests/contrib/test_pytest_plugin.py @@ -0,0 +1,135 @@ +import pytest +from unittest.mock import Mock, patch +import requests +import httpx +import aiohttp +import sys +import importlib +from multi_api_mocker.contrib.pytest_plugin import ( + setup_api_mocks, + setup_http_mocks, + setup_httpx_mocks, + setup_aiohttp_mocks, + add_response, + add_aiohttp_response, +) +from multi_api_mocker.definitions import MockAPIResponse + + +@pytest.mark.parametrize( + "setup_api_mocks", + [[MockAPIResponse(url="https://example.com", method="GET")]], + indirect=True, +) +def test_setup_api_mocks_deprecation_warning(setup_api_mocks): + requests.get("https://example.com") + + +@pytest.mark.parametrize( + "setup_http_mocks", + [[MockAPIResponse(url="https://example.com", method="GET")]], + indirect=True, +) +def test_setup_http_mocks(setup_http_mocks): + requests.get("https://example.com") + assert setup_http_mocks is not None + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [[MockAPIResponse(url="https://example.com", method="GET")]], + indirect=True, +) +def test_setup_httpx_mocks(setup_httpx_mocks): + with httpx.Client() as client: + client.get("https://example.com") + assert setup_httpx_mocks is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [[MockAPIResponse(url="https://example.com", method="GET", status_code=200)]], + indirect=True, +) +async def test_setup_aiohttp_mocks(setup_aiohttp_mocks): + async with aiohttp.ClientSession() as session: + async with session.get("https://example.com") as response: + assert response.status == 200 + assert setup_aiohttp_mocks is not None + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + [ + MockAPIResponse(url="https://example.com", method="GET"), + MockAPIResponse(url="https://example.com", method="GET"), + ] + ] + ], + indirect=True, +) +def test_setup_httpx_mocks_with_nested_list(setup_httpx_mocks): + with httpx.Client() as client: + client.get("https://example.com") + client.get("https://example.com") + assert setup_httpx_mocks is not None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + [ + MockAPIResponse( + url="https://example.com", method="GET", status_code=200 + ), + MockAPIResponse( + url="https://example.com", method="GET", status_code=200 + ), + ] + ] + ], + indirect=True, +) +async def test_setup_aiohttp_mocks_with_nested_list(setup_aiohttp_mocks): + async with aiohttp.ClientSession() as session: + async with session.get("https://example.com") as response: + assert response.status == 200 + async with session.get("https://example.com") as response: + assert response.status == 200 + assert setup_aiohttp_mocks is not None + + +def test_add_response_invalid_type(): + with pytest.raises(ValueError): + add_response(Mock(), "not a mock") + + +def test_add_aiohttp_response_invalid_type(): + with pytest.raises(ValueError): + add_aiohttp_response(Mock(), "not a mock") + + +def test_import_error_requests_mock(): + with patch.dict("sys.modules", {"requests_mock": None}): + import multi_api_mocker.contrib.pytest_plugin as pytest_plugin + importlib.reload(pytest_plugin) + assert not pytest_plugin.requests_mock_available + + +def test_import_error_httpx(): + with patch.dict("sys.modules", {"pytest_httpx": None}): + import multi_api_mocker.contrib.pytest_plugin as pytest_plugin + importlib.reload(pytest_plugin) + assert not pytest_plugin.httpx_available + + +def test_import_error_aiohttp(): + with patch.dict("sys.modules", {"aioresponses": None}): + import multi_api_mocker.contrib.pytest_plugin as pytest_plugin + importlib.reload(pytest_plugin) + assert not pytest_plugin.aiohttp_available \ No newline at end of file diff --git a/tests/test_aiohttp_utils.py b/tests/test_aiohttp_utils.py new file mode 100644 index 0000000..26d6720 --- /dev/null +++ b/tests/test_aiohttp_utils.py @@ -0,0 +1,19 @@ +from unittest.mock import Mock +from multi_api_mocker.aiohttp_utils import AIOHTTPMockSet +from multi_api_mocker.definitions import MockAPIResponse + + +def test_aiohttp_mock_set(): + mock_response = MockAPIResponse(endpoint_name="test") + mock_set = AIOHTTPMockSet([mock_response], Mock()) + assert len(mock_set) == 1 + assert mock_set["test"] == mock_response + assert list(mock_set) == [mock_response] + assert repr(mock_set) == "" + + +def test_aiohttp_mock_set_with_empty_list(): + mock_set = AIOHTTPMockSet([], Mock()) + assert len(mock_set) == 0 + assert list(mock_set) == [] + assert repr(mock_set) == "" diff --git a/tests/test_definitions.py b/tests/test_definitions.py index b4da775..54c2a4b 100644 --- a/tests/test_definitions.py +++ b/tests/test_definitions.py @@ -16,6 +16,8 @@ def test_initialization_with_no_kwargs(self): assert mock.json is None assert mock.text is None assert mock.exc is None + assert mock.headers is None + assert mock.callback is None def test_initialization_with_kwargs(self): mock = MockAPIResponse( @@ -26,6 +28,8 @@ def test_initialization_with_kwargs(self): text="Hello, world!", endpoint_name="MockAPIResponse", exc=Exception, + headers={"X-Custom-Header": "Test-Value"}, + callback=lambda: None, ) assert mock.url == "https://example.com" assert mock.method == "GET" @@ -34,6 +38,8 @@ def test_initialization_with_kwargs(self): assert mock.json == {"foo": "bar"} assert mock.text == "Hello, world!" assert mock.exc is Exception + assert mock.headers == {"X-Custom-Header": "Test-Value"} + assert mock.callback is not None def test_subclassing(self): class MockAPIResponseSubclass(MockAPIResponse): @@ -44,6 +50,8 @@ class MockAPIResponseSubclass(MockAPIResponse): default_json = {"foo": "bar"} default_text = "Hello, world!" default_exc = Exception + default_headers = {"X-Custom-Header": "Test-Value"} + default_callback = lambda: None mock = MockAPIResponseSubclass() assert mock.url == "https://example.com" @@ -53,6 +61,17 @@ class MockAPIResponseSubclass(MockAPIResponse): assert mock.json == {"foo": "bar"} assert mock.text == "Hello, world!" assert mock.exc is Exception + assert mock.headers == {"X-Custom-Header": "Test-Value"} + assert mock.callback is not None + + def test_repr(self): + mock = MockAPIResponse( + url="https://example.com", method="GET", status_code=200 + ) + assert ( + repr(mock) + == "MockAPIResponse(url=https://example.com, method=GET, status_code=200)" + ) def test_subclassing_with_exception_instance(self): class MockAPIResponseSubclass(MockAPIResponse): @@ -108,6 +127,20 @@ class MockAPIResponseSubclass(MockAPIResponse): mock = MockAPIResponseSubclass(partial_json={"bar": "foo"}) assert mock.json == {"bar": "foo", "foo": "bar"} + def test_json_property(self): + class MockAPIResponseSubclass(MockAPIResponse): + default_json = {"foo": "bar"} + + mock = MockAPIResponseSubclass(json={"bar": "foo"}) + assert mock.json == {"bar": "foo"} + + def test_text_property(self): + class MockAPIResponseSubclass(MockAPIResponse): + default_text = "Hello, world!" + + mock = MockAPIResponseSubclass(text="Goodbye, world!") + assert mock.text == "Goodbye, world!" + def test_subclassing_with_invalid_url(self): with pytest.raises(TypeError) as exc_info: # The following line triggers the validation check @@ -161,6 +194,22 @@ def test_subclassing_with_invalid_url(self): "must be a subclass or instance of Exception or None, got 'str': " "'NotATypeOrNone'.", ), + ( + "default_headers", + "NotADict", + ( + "The 'default_headers' attribute in subclass 'MockAPIResponseSubclass' " + "must be of type 'dict, None', got 'str': 'NotADict'." + ), + ), + ( + "default_callback", + "NotACallable", + ( + "The 'default_callback' attribute in subclass 'MockAPIResponseSubclass' " + "must be of type 'Callable, None', got 'str': 'NotACallable'." + ), + ), ], ids=[ "method", @@ -168,6 +217,8 @@ def test_subclassing_with_invalid_url(self): "default_status_code", "default_text", "default_exc", + "default_headers", + "default_callback", ], ) def test_invalid_class_attribute_definition( @@ -184,3 +235,31 @@ def test_invalid_class_attribute_definition( ) assert str(exc_info.value) == expected_message + + def test_default_json(self): + class MockAPIResponseSubclass(MockAPIResponse): + default_json = {"foo": "bar"} + + mock = MockAPIResponseSubclass() + assert mock._default_json(200) == {"foo": "bar"} + + def test_default_text(self): + class MockAPIResponseSubclass(MockAPIResponse): + default_text = "Hello, world!" + + mock = MockAPIResponseSubclass() + assert mock._default_text(200) == "Hello, world!" + + def test_invalid_default_exc(self): + with pytest.raises(TypeError) as exc_info: + type( + "MockAPIResponseSubclass", + (MockAPIResponse,), + {"default_exc": "not an exception"}, + ) + assert ( + str(exc_info.value) + == "The 'default_exc' attribute in subclass 'MockAPIResponseSubclass' " + "must be a subclass or instance of Exception or None, got 'str': " + "'not an exception'." + ) \ No newline at end of file diff --git a/tests/test_http_utils.py b/tests/test_http_utils.py new file mode 100644 index 0000000..5561956 --- /dev/null +++ b/tests/test_http_utils.py @@ -0,0 +1,100 @@ +from unittest.mock import Mock +import pytest +from multi_api_mocker.http_utils import RequestsMockSet, group_by_url, add_mock_to_group +from multi_api_mocker.definitions import MockAPIResponse +from collections import defaultdict + + +def test_requests_mock_set(): + mock_response = MockAPIResponse(endpoint_name="test") + mock_set = RequestsMockSet([mock_response], Mock(), {"test": "matcher"}) + assert len(mock_set) == 1 + assert mock_set["test"] == mock_response + assert list(mock_set) == [mock_response] + assert repr(mock_set) == "" + assert mock_set.get_matcher("test") == "matcher" + + +def test_requests_mock_set_with_empty_list(): + mock_set = RequestsMockSet([], Mock()) + assert len(mock_set) == 0 + assert list(mock_set) == [] + assert repr(mock_set) == "" + + +def test_group_by_url(): + mock_response_1 = MockAPIResponse( + url="https://example.com", method="GET", json={"foo": "bar"} + ) + mock_response_2 = MockAPIResponse( + url="https://example.com", method="GET", json={"foo": "baz"} + ) + mock_response_3 = MockAPIResponse( + url="https://example.com", method="POST", json={"foo": "bar"} + ) + mock_response_4 = MockAPIResponse( + url="https://example.com/2", method="GET", json={"foo": "bar"} + ) + mock_response_5 = MockAPIResponse( + url="https://example.com/2", method="GET", exc=Exception + ) + + grouped = group_by_url( + [ + mock_response_1, + mock_response_2, + mock_response_3, + mock_response_4, + mock_response_5, + ] + ) + assert len(grouped) == 3 + assert grouped[0].url == "https://example.com" + assert grouped[0].method == "GET" + assert len(grouped[0].responses) == 2 + assert grouped[1].url == "https://example.com" + assert grouped[1].method == "POST" + assert len(grouped[1].responses) == 1 + assert grouped[2].url == "https://example.com/2" + assert grouped[2].method == "GET" + assert len(grouped[2].responses) == 2 + + +def test_group_by_url_with_nested_list(): + mock_response_1 = MockAPIResponse( + url="https://example.com", method="GET", json={"foo": "bar"} + ) + mock_response_2 = MockAPIResponse( + url="https://example.com", method="GET", json={"foo": "baz"} + ) + grouped = group_by_url([[mock_response_1, mock_response_2]]) + assert len(grouped) == 1 + assert grouped[0].url == "https://example.com" + assert grouped[0].method == "GET" + assert len(grouped[0].responses) == 2 + + +def test_group_by_url_with_invalid_type(): + with pytest.raises(ValueError): + group_by_url(["not a mock"]) + + +def test_group_by_url_with_nested_invalid_type(): + with pytest.raises(ValueError): + group_by_url([["not a mock"]]) + + +def test_add_mock_to_group(): + grouped_mocks = defaultdict(list) + mock = MockAPIResponse( + url="https://example.com", + method="GET", + json={"foo": "bar"}, + text="test", + status_code=200, + exc=Exception, + headers={"X-Custom-Header": "Test-Value"}, + ) + add_mock_to_group(grouped_mocks, mock) + assert len(grouped_mocks) == 1 + assert len(grouped_mocks[("https://example.com", "GET")]) == 1 diff --git a/tests/test_httpx_utils.py b/tests/test_httpx_utils.py new file mode 100644 index 0000000..d56a128 --- /dev/null +++ b/tests/test_httpx_utils.py @@ -0,0 +1,38 @@ +from unittest.mock import Mock +import pytest +from multi_api_mocker.httpx_utils import HTTPXMockSet +from multi_api_mocker.definitions import MockAPIResponse + + +def test_httpx_mock_set(): + mock_response = MockAPIResponse(endpoint_name="test", url="https://example.com") + mock_set = HTTPXMockSet([mock_response], Mock()) + assert len(mock_set) == 1 + assert mock_set["test"] == mock_response + assert list(mock_set) == [mock_response] + assert repr(mock_set) == "" + assert mock_set.get_matcher("https://example.com") is not None + + +def test_httpx_mock_set_with_empty_list(): + mock_set = HTTPXMockSet([], Mock()) + assert len(mock_set) == 0 + assert list(mock_set) == [] + assert repr(mock_set) == "" + + +def test_get_request(): + mock_response = MockAPIResponse(endpoint_name="test", url="https://example.com") + mock_httpx = Mock() + mock_httpx.get_requests.return_value = [Mock(url="https://example.com")] + mock_set = HTTPXMockSet([mock_response], mock_httpx) + assert mock_set.get_request("test") is not None + + +def test_get_request_not_found(): + mock_response = MockAPIResponse(endpoint_name="test", url="https://example.com") + mock_httpx = Mock() + mock_httpx.get_requests.return_value = [] + mock_set = HTTPXMockSet([mock_response], mock_httpx) + with pytest.raises(KeyError): + mock_set.get_request("test") \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..a1e7dd8 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,40 @@ +from multi_api_mocker.models import ResponseKwargs, MockConfiguration + + +def test_response_kwargs_to_dict(): + kwargs = ResponseKwargs( + text="test", + status_code=200, + json={"foo": "bar"}, + exc=Exception, + headers={"X-Custom-Header": "Test-Value"}, + ) + assert kwargs.to_dict() == { + "text": "test", + "status_code": 200, + "json": {"foo": "bar"}, + "exc": Exception, + "headers": {"X-Custom-Header": "Test-Value"}, + } + + +def test_response_kwargs_to_dict_with_none_values(): + kwargs = ResponseKwargs( + text="test", + status_code=200, + ) + assert kwargs.to_dict() == { + "text": "test", + "status_code": 200, + } + + +def test_mock_configuration(): + config = MockConfiguration( + url="https://example.com", + method="GET", + responses=[{"json": {"foo": "bar"}}], + ) + assert config.url == "https://example.com" + assert config.method == "GET" + assert config.responses == [{"json": {"foo": "bar"}}] diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py index bfca237..9cb7b6e 100644 --- a/tests/test_pytest/test_aiohttp_fixtures.py +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -1,6 +1,7 @@ import pytest from aiohttp import ClientSession from multi_api_mocker.definitions import MockAPIResponse +from aioresponses import CallbackResult @pytest.mark.asyncio @@ -43,3 +44,87 @@ async def test_aiohttp_exception_mocking(setup_aiohttp_mocks): async with ClientSession() as session: with pytest.raises(OSError): await session.get("https://example.com/api/error") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_headers", + method="GET", + json={"message": "Success"}, + status_code=200, + headers={"X-Custom-Header": "Test-Value"}, + ) + ] + ], + indirect=True, +) +async def test_aiohttp_mocking_with_headers(setup_aiohttp_mocks): + async with ClientSession() as session: + async with session.get("https://example.com/api/test_headers") as response: + assert response.status == 200 + assert await response.json() == {"message": "Success"} + assert response.headers["X-Custom-Header"] == "Test-Value" + + +def callback_test(url, **kwargs): + return CallbackResult( + status=200, + payload={"message": "Callback executed"} + ) + + +def callback_with_headers_test(url, **kwargs): + return CallbackResult( + status=200, + payload={"message": "Callback executed"}, + headers={"X-Callback-Header": "Callback-Value"}, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=callback_test, + ) + ] + ], + indirect=True, +) +async def test_aiohttp_mocking_with_callback(setup_aiohttp_mocks): + async with ClientSession() as session: + async with session.get("https://example.com/api/test_callback") as response: + assert response.status == 200 + assert await response.json() == {"message": "Callback executed"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback_headers", + method="GET", + callback=callback_with_headers_test, + ) + ] + ], + indirect=True, +) +async def test_aiohttp_mocking_with_callback_headers(setup_aiohttp_mocks): + async with ClientSession() as session: + async with session.get( + "https://example.com/api/test_callback_headers" + ) as response: + assert response.status == 200 + assert await response.json() == {"message": "Callback executed"} + assert response.headers["X-Callback-Header"] == "Callback-Value" diff --git a/tests/test_pytest/test_fixtures.py b/tests/test_pytest/test_fixtures.py index 7efac3e..ca73dde 100644 --- a/tests/test_pytest/test_fixtures.py +++ b/tests/test_pytest/test_fixtures.py @@ -6,6 +6,22 @@ from multi_api_mocker.definitions import MockAPIResponse from . import mocks +try: + from multi_api_mocker.contrib.pytest_plugin import setup_http_mocks # noqa: F401 +except ImportError: + pass + +try: + from multi_api_mocker.contrib.pytest_plugin import setup_httpx_mocks # noqa: F401 +except ImportError: + pass + +try: + from multi_api_mocker.contrib.pytest_plugin import setup_aiohttp_mocks # noqa: F401 + from multi_api_mocker.contrib.pytest_plugin import aiohttp_available +except ImportError: + aiohttp_available = False + @pytest.mark.parametrize( "setup_http_mocks", diff --git a/tests/test_pytest/test_http_fixtures.py b/tests/test_pytest/test_http_fixtures.py new file mode 100644 index 0000000..f7c0eea --- /dev/null +++ b/tests/test_pytest/test_http_fixtures.py @@ -0,0 +1,261 @@ +import pytest +import requests +from requests import RequestException + +from multi_api_mocker.definitions import MockAPIResponse +from . import mocks + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/commit", + method="POST", + json={"message": "Commit successful", "commit_id": "abc123"}, + ), + MockAPIResponse( + url="https://example.com/api/push", + method="POST", + json={"message": "Push successful", "push_id": "xyz456"}, + ), + ] + ], + indirect=True, +) +def test_commit_and_push(setup_http_mocks): + # Perform the commit API call + commit_response = requests.post("https://example.com/api/commit") + assert commit_response.json() == { + "message": "Commit successful", + "commit_id": "abc123", + } + + # Perform the push API call + push_response = requests.post("https://example.com/api/push") + assert push_response.json() == {"message": "Push successful", "push_id": "xyz456"} + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(), + ] + ], + indirect=True, +) +def test_single_flow_multiple_api_calls(setup_http_mocks): + mock_set = setup_http_mocks + # Perform the API call + response = requests.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = requests.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + response = requests.post("https://example.com/api/push") + assert response.json() == mock_set["Push"].json + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + # Scenario 1: Push fails with a 400 error + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(status_code=400, json={"error": "Push failed"}), + ] + ), + # Scenario 2: Force push succeeds after a failed push + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(status_code=400, json={"error": "Push failed"}), + mocks.ForcePush(), + ] + ), + ], + indirect=True, +) +def test_multiple_scenarios(setup_http_mocks): + mock_set = setup_http_mocks + # Perform the API call + response = requests.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = requests.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + response = requests.post("https://example.com/api/push") + assert response.status_code == 400 + assert response.json() == mock_set["Push"].json + + if "ForcePush" in mock_set: + response = requests.post("https://example.com/api/force-push") + assert response.json() == mock_set["ForcePush"].json + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + # Scenario 1: Push fails with a 400 error + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(exc=RequestException), + ] + ), + # Scenario 2: Force fails with 400 using `default_exc` + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.PushTimeoutRequestsError(), + ] + ), + ], + indirect=True, +) +def test_exception(setup_http_mocks): + mock_set = setup_http_mocks + # Perform the API call + response = requests.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = requests.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + with pytest.raises(RequestException): + requests.post("https://example.com/api/push") + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + ([mocks.Fork(), mocks.Commit(), mocks.Push(partial_json={"id": "partial_id"})]), + ], + indirect=True, +) +def test_partial_json(setup_http_mocks): + mock_set = setup_http_mocks + + response = requests.post("https://example.com/api/push") + expected_json = mock_set["Push"].json + expected_json["id"] = "partial_id" + + assert response.status_code == 200 + assert response.json() == expected_json + + +@pytest.mark.parametrize( + "user_email, setup_http_mocks", + [ + ("dev1@example.com", [mocks.Fork(), mocks.Commit(), mocks.Push()]), + ( + "dev2@example.com", + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(json={"message": "Pushed with different user"}), + ], + ), + ], + indirect=["setup_http_mocks"], +) +def test_flexible_parametrization(user_email, setup_http_mocks): + mock_set = setup_http_mocks + + response = requests.post("https://example.com/api/push", json={"email": user_email}) + expected_json = mock_set["Push"].json + + assert response.status_code == 200 + assert response.json() == expected_json + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + ( + [ + mocks.Push(), + mocks.SecondPush(), + ] + ), + ], + indirect=True, +) +def test_same_endpoint_url(setup_http_mocks): + mock_set = setup_http_mocks + + response = requests.post("https://example.com/api/push") + assert response.json() == mock_set["Push"].json + + response2 = requests.post("https://example.com/api/push") + assert response2.json() == mock_set["SecondPush"].json + + matcher = mock_set.get_matcher("https://example.com/api/push") + assert matcher == mock_set.get_matcher(mock_set["Push"].url) + + assert matcher.call_count == 2 + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_headers", + method="GET", + json={"message": "Success"}, + status_code=200, + headers={"X-Custom-Header": "Test-Value"}, + ) + ] + ], + indirect=True, +) +def test_http_mocking_with_headers(setup_http_mocks): + response = requests.get("https://example.com/api/test_headers") + assert response.status_code == 200 + assert response.json() == {"message": "Success"} + assert response.headers["X-Custom-Header"] == "Test-Value" + + +def callback_test(request, context): + context.status_code = 200 + context.headers["X-Callback-Header"] = "Callback-Value" + return {"message": "Callback executed"} + + +@pytest.mark.parametrize( + "setup_http_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + json=callback_test, + ) + ] + ], + indirect=True, +) +def test_http_mocking_with_callback(setup_http_mocks): + response = requests.get("https://example.com/api/test_callback") + assert response.status_code == 200 + assert response.json() == {"message": "Callback executed"} + assert response.headers["X-Callback-Header"] == "Callback-Value" \ No newline at end of file diff --git a/tests/test_pytest/test_httpx_fixtures.py b/tests/test_pytest/test_httpx_fixtures.py new file mode 100644 index 0000000..42fa3cc --- /dev/null +++ b/tests/test_pytest/test_httpx_fixtures.py @@ -0,0 +1,320 @@ +import httpx +import pytest + +from multi_api_mocker.definitions import MockAPIResponse +from . import mocks + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/commit", + method="POST", + json={"message": "Commit successful", "commit_id": "abc123"}, + ), + MockAPIResponse( + url="https://example.com/api/push", + method="POST", + json={"message": "Push successful", "push_id": "xyz456"}, + ), + ] + ], + indirect=True, +) +def test_commit_and_push(setup_httpx_mocks): + # Perform the commit API call + with httpx.Client() as client: + commit_response = client.post("https://example.com/api/commit") + assert commit_response.json() == { + "message": "Commit successful", + "commit_id": "abc123", + } + + # Perform the push API call + push_response = client.post("https://example.com/api/push") + assert push_response.json() == { + "message": "Push successful", + "push_id": "xyz456", + } + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(), + ] + ], + indirect=True, +) +def test_single_flow_multiple_api_calls(setup_httpx_mocks): + mock_set = setup_httpx_mocks + # Perform the API call + with httpx.Client() as client: + response = client.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = client.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + response = client.post("https://example.com/api/push") + assert response.json() == mock_set["Push"].json + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(status_code=400, json={"error": "Push failed"}), + ] + ), + ], + indirect=True, +) +def test_multiple_scenarios_push_fails(setup_httpx_mocks): + mock_set = setup_httpx_mocks + # Perform the API call + with httpx.Client() as client: + response = client.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = client.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + response = client.post("https://example.com/api/push") + assert response.status_code == 400 + assert response.json() == mock_set["Push"].json + + with pytest.raises(httpx.TimeoutException): + client.post("https://example.com/api/force-push") + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(status_code=400, json={"error": "Push failed"}), + mocks.ForcePush(), + ] + ), + ], + indirect=True, +) +def test_multiple_scenarios_force_push_succeeds(setup_httpx_mocks): + mock_set = setup_httpx_mocks + # Perform the API call + with httpx.Client() as client: + response = client.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = client.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + response = client.post("https://example.com/api/push") + assert response.status_code == 400 + assert response.json() == mock_set["Push"].json + + response = client.post("https://example.com/api/force-push") + assert response.json() == mock_set["ForcePush"].json + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + # Scenario 1: Push fails with a 400 error + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push( + exc=httpx.RequestError( + "Request error", + request=httpx.Request("POST", "https://example.com/api/push"), + ) + ), + ] + ), + # Scenario 2: Force fails with 400 using `default_exc` + ( + [ + mocks.Fork(), + mocks.Commit(), + mocks.PushTimeoutHTTPXError(), + ] + ), + ], + indirect=True, +) +def test_exception(setup_httpx_mocks): + mock_set = setup_httpx_mocks + # Perform the API calls + with httpx.Client() as client: + response = client.post("https://example.com/api/fork") + + # Assert the response matches what was defined in the Fork mock + assert response.json() == mock_set["Fork"].json + + response = client.get("https://example.com/api/commit") + assert response.json() == mock_set["Commit"].json + + with pytest.raises(httpx.RequestError): + client.post("https://example.com/api/push") + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + ([mocks.Push(partial_json={"id": "partial_id"})]), + ], + indirect=True, +) +def test_partial_json(setup_httpx_mocks): + mock_set = setup_httpx_mocks + + with httpx.Client() as client: + response = client.post("https://example.com/api/push") + expected_json = mock_set["Push"].json + expected_json["id"] = "partial_id" + + assert response.status_code == 200 + assert response.json() == expected_json + + +@pytest.mark.parametrize( + "user_email, setup_httpx_mocks", + [ + ("dev1@example.com", [mocks.Push()]), + ( + "dev2@example.com", + [ + mocks.Push(json={"message": "Pushed with different user"}), + ], + ), + ], + indirect=["setup_httpx_mocks"], +) +def test_flexible_parametrization(user_email, setup_httpx_mocks): + mock_set = setup_httpx_mocks + + with httpx.Client() as client: + response = client.post( + "https://example.com/api/push", json={"email": user_email} + ) + expected_json = mock_set["Push"].json + + assert response.status_code == 200 + assert response.json() == expected_json + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + ( + [ + mocks.Push(), + mocks.SecondPush(), + ] + ), + ], + indirect=True, +) +def test_same_endpoint_url(setup_httpx_mocks): + mock_set = setup_httpx_mocks + + with httpx.Client() as client: + response = client.post("https://example.com/api/push") + assert response.json() == mock_set["Push"].json + + response2 = client.post("https://example.com/api/push") + assert response2.json() == mock_set["SecondPush"].json + + matcher = mock_set.get_matcher("https://example.com/api/push") + assert matcher == mock_set.get_matcher(mock_set["Push"].url) + + assert len(mock_set.httpx_mock.get_requests()) == 2 + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_headers", + method="GET", + json={"message": "Success"}, + status_code=200, + headers={"X-Custom-Header": "Test-Value"}, + ) + ] + ], + indirect=True, +) +def test_httpx_mocking_with_headers(setup_httpx_mocks): + with httpx.Client() as client: + response = client.get("https://example.com/api/test_headers") + assert response.status_code == 200 + assert response.json() == {"message": "Success"} + assert response.headers["X-Custom-Header"] == "Test-Value" + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=lambda request: httpx.Response( + 200, json={"message": "Callback executed"} + ), + ) + ] + ], + indirect=True, +) +def test_httpx_mocking_with_callback(setup_httpx_mocks): + with httpx.Client() as client: + response = client.get("https://example.com/api/test_callback") + assert response.status_code == 200 + assert response.json() == {"message": "Callback executed"} + + +@pytest.mark.parametrize( + "setup_httpx_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback_headers", + method="GET", + callback=lambda request: httpx.Response( + 200, + json={"message": "Callback executed"}, + headers={"X-Callback-Header": "Callback-Value"}, + ), + ) + ] + ], + indirect=True, +) +def test_httpx_mocking_with_callback_headers(setup_httpx_mocks): + with httpx.Client() as client: + response = client.get("https://example.com/api/test_callback_headers") + assert response.status_code == 200 + assert response.json() == {"message": "Callback executed"} + assert response.headers["X-Callback-Header"] == "Callback-Value" \ No newline at end of file