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 7fc7ada..9ae4ed5 100644 --- a/README.md +++ b/README.md @@ -1,417 +1,374 @@ # 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: - -```bash -pip install multi-api-mocker[httpx] -``` +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. -If you want to install Multi-API Mocker with support for both `requests_mock` and `pytest_httpx`, you can use the `all` extra: +- **For `requests` support:** + ```bash + pip install multi-api-mocker[http] + ``` -```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 (`MockAPIResponse`) -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 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. -#### 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", + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_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/data", + method="GET", + json={"message": "Success!"}, + status_code=200 + ) + ]) ], - indirect=True, + 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"} +def test_direct_mock(setup_http_mocks): + # Your test logic here + ... ``` -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. - -## API Reference - -### MockAPIResponse Class - -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. +### Subclassing `MockAPIResponse` for Reusability -#### 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 +import requests # or httpx, aiohttp -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", # Or setup_httpx_mocks, setup_aiohttp_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. +## Supported Libraries and Their MockSet Objects -#### Class Attributes and Constructor Parameters +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. -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. +### For `requests` (`requests-mock`) -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. +- **Fixture:** `setup_http_mocks` +- **Returned Object:** `RequestsMockSet` -3. **endpoint_name** (`str`): A human-readable identifier for the API endpoint. This name facilitates easy tracking and referencing of mock responses in tests. +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`). -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 - -Using `setup_http_mocks` streamlines the process of configuring mock responses in pytest. It enhances test readability and maintainability by: - -- **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. - -#### How It Works - -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: - -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:** +```python +import requests +from . import mocks # Assuming mocks.py with a UserProfile subclass -#### Example Usage +@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" -1. **Defining Mock Responses**: + # The RequestsMockSet gives you access to the underlying matcher + matcher = setup_http_mocks.get_matcher("UserProfile") + assert matcher.call_count == 1 +``` - Create subclasses of `MockAPIResponse` for each API endpoint you need to mock. +### For `httpx` (`pytest-httpx`) - ```python - class Fork(MockAPIResponse): - url = "https://example.com/api/fork" - method = "POST" - # ... other default attributes ... - ``` +- **Fixture:** `setup_httpx_mocks` +- **Returned Object:** `HTTPXMockSet` -2. **Using the Fixture in Tests**: +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. - 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. +**Example:** +```python +import httpx +from . import 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 ... - ``` +@pytest.mark.parametrize( + "setup_httpx_mocks", + [([mocks.UserProfile()])], + indirect=True +) +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" - In this example, the test function `test_repository_workflow` receives a `RequestsMockSet` object containing the mocks for `Fork`, `Commit`, and `Push` endpoints. + # Use get_request() to inspect the request after it was made + request = setup_httpx_mocks.get_request("UserProfile") + assert request.method == "GET" +``` +### For `aiohttp` (`aioresponses`) -### `RequestsMockSet` Class +- **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 `get_request()` method for inspecting requests *after* they have been executed. -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. +**Example:** +```python +import aiohttp +import pytest +from . import mocks -#### Constructor Parameters +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [([mocks.UserProfile()])], + indirect=True +) +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" +``` -- **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. +## Advanced Usage -#### Functionality +### Simulating Exceptions -`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. +You can simulate network errors or other exceptions by passing an `exc` argument to your `MockAPIResponse`. -#### How to Use `RequestsMockSet` +```python +import requests +from requests.exceptions import ConnectTimeout -1. **Initialization**: `RequestsMockSet` is initialized with a list of `MockAPIResponse` instances. These can represent different API calls you intend to mock in your tests. +@pytest.mark.parametrize( + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks + [([ + mocks.UserProfile(exc=ConnectTimeout("Connection timed out")) + ])], + indirect=True +) +def test_with_exception(setup_http_mocks): + with pytest.raises(ConnectTimeout): + requests.get("https://api.example.com/user/profile") +``` - ```python - mock_set = RequestsMockSet([Fork(), Commit(), Push(), ForcePush()]) - print(mock_set) - # Output: - ``` +### Partial JSON Updates -2. **Accessing Specific Mocks**: To access a specific mock response, use the endpoint name as the key: +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 - fork_response = mock_set["Fork"] - # Output: Fork(url=https://example.com/api/fork, method=POST, status_code=200) - ``` +```python +@pytest.mark.parametrize( + "setup_http_mocks", # Or setup_httpx_mocks, setup_aiohttp_mocks + [([ + mocks.UserProfile(partial_json={"name": "John Smith"}) + ])], + indirect=True +) +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" +``` -3. **Iterating Over Mocks**: You can iterate over all the mocks in the `RequestsMockSet`: +### Advanced Mocking with `MockAPIResponse` - ```python - for mock in mock_set: - # Perform checks or operations on each mock response - ``` +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. -4. **Converting to a List**: To get a list of all mock responses in the `RequestsMockSet`, simply use `list(mock_set)`: +- **`headers`**: A dictionary of response headers. +- **`callback`**: A function that will be called to generate a dynamic response. - ```python - all_mocks = list(mock_set) - ``` +#### `aiohttp` -### Usage of `MockAPIResponse` in Different Testing Scenarios +The callback function will receive the URL of the request and any other keyword arguments, and it should return an `aioresponses.CallbackResult` object. -#### Multiple Parametrized Tests with API Calls in Sequence +**Example:** +```python +import pytest +from aiohttp import ClientSession +from multi_api_mocker.definitions import MockAPIResponse +from aioresponses import CallbackResult -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. +# 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") -```python +@pytest.mark.asyncio @pytest.mark.parametrize( - "setup_http_mocks", + "setup_aiohttp_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"}), - ] - ), + [ + # 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, ) -def test_multiple_scenarios(setup_http_mocks): - mock_set = setup_http_mocks - # ... Perform API calls and assert responses for each mock ... +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" ``` -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. - +#### `requests` -#### 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. +The callback function will receive the request object and the context, and it should return a JSON serializable object. +**Example:** ```python -@pytest.mark.parametrize( - "setup_http_mocks", - [([mocks.Fork(), mocks.Commit(), mocks.Push(exc=RequestException)])], - indirect=True -) -def test_api_exception_handling(setup_http_mocks): - mock_set = setup_http_mocks - # Handling and asserting the exception - ... -``` +import pytest +import requests +from multi_api_mocker.definitions import MockAPIResponse -#### 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. +# Example of a callback function +def dynamic_callback(request, context): + context.status_code = 200 + return {"message": "Callback executed"} -```python @pytest.mark.parametrize( "setup_http_mocks", - [([mocks.Fork(), mocks.Commit(), mocks.Push(partial_json={"id": "partial_id"})])], - indirect=True + [ + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=dynamic_callback, + ) + ] + ], + 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" - +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() == expected_json + assert response.json() == {"message": "Callback executed"} ``` -### Flexible Parametrization with Indirect Fixture Usage +#### `httpx` -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. +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( - "user_email, setup_http_mocks", + "setup_httpx_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"}), - ], - ), + [ + MockAPIResponse( + url="https://example.com/api/test_callback", + method="GET", + callback=dynamic_callback, + ) + ] ], - indirect=["setup_http_mocks"], + indirect=True, ) -def test_flexible_parametrization(user_email, setup_http_mocks): - mock_set = setup_http_mocks - # Perform API calls and assertions here -``` - -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 +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"} ``` -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. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/multi_api_mocker/aiohttp_utils.py b/multi_api_mocker/aiohttp_utils.py new file mode 100644 index 0000000..4c93b3d --- /dev/null +++ b/multi_api_mocker/aiohttp_utils.py @@ -0,0 +1,95 @@ +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 + 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}>" \ 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 a0eea2b..a735c82 100644 --- a/multi_api_mocker/contrib/pytest_plugin.py +++ b/multi_api_mocker/contrib/pytest_plugin.py @@ -22,59 +22,28 @@ 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") 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.", @@ -88,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 @@ -100,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. - """ - mock_definitions: List[ - Union[MockAPIResponse, List[MockAPIResponse]] - ] = request.param - + 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) - yield HTTPXMockSet(mock_definitions, httpx_mock) + for mock_definition in flattened_definitions: + add_response(httpx_mock, mock_definition) + + yield HTTPXMockSet(flattened_definitions, httpx_mock) def add_response(httpx_mock: HTTPXMock, mock_definition: MockAPIResponse): if not isinstance(mock_definition, MockAPIResponse): @@ -140,10 +109,64 @@ 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, + ) + + +if aiohttp_available: + + @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): + flattened_definitions.extend(mock_definition) + else: + flattened_definitions.append(mock_definition) + + for mock_definition in flattened_definitions: + add_aiohttp_response(m, mock_definition) + + yield AIOHTTPMockSet(flattened_definitions, m) + + 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.add( + 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, + 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 3218669..c0f6609 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 @@ -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 5cdb782..d6ecf60 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,16 +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==23.11.0 - -pytest_httpx==0.22.0; python_version < '3.9' -pytest_httpx==0.30.0; python_version >= '3.9' - -requests_mock==1.11.0 - -pip>=23.3 - +-e . +black +isort +flake8 +mypy +pytest +pytest-cov +pytest-asyncio +requests-mock +httpx +aiohttp +aioresponses==0.7.8 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/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/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/conftest.py b/tests/test_pytest/conftest.py index 119211a..ad5f8e4 100644 --- a/tests/test_pytest/conftest.py +++ b/tests/test_pytest/conftest.py @@ -1,3 +1,15 @@ -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 + from multi_api_mocker.contrib.pytest_plugin import aiohttp_available +except ImportError: + aiohttp_available = False diff --git a/tests/test_pytest/test_aiohttp_fixtures.py b/tests/test_pytest/test_aiohttp_fixtures.py new file mode 100644 index 0000000..9cb7b6e --- /dev/null +++ b/tests/test_pytest/test_aiohttp_fixtures.py @@ -0,0 +1,130 @@ +import pytest +from aiohttp import ClientSession +from multi_api_mocker.definitions import MockAPIResponse +from aioresponses import CallbackResult + + +@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"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_aiohttp_mocks", + [ + [ + MockAPIResponse( + url="https://example.com/api/error", + method="GET", + exc=OSError("Connection refused"), + ) + ] + ], + indirect=True, +) +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 ee872c8..ca73dde 100644 --- a/tests/test_pytest/test_fixtures.py +++ b/tests/test_pytest/test_fixtures.py @@ -6,61 +6,42 @@ 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 -@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", - } +try: + from multi_api_mocker.contrib.pytest_plugin import setup_httpx_mocks # noqa: F401 +except ImportError: + pass - # Perform the push API call - push_response = requests.post("https://example.com/api/push") - assert push_response.json() == {"message": "Push successful", "push_id": "xyz456"} +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", [ - ( - [ - 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, ) -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() == { @@ -74,20 +55,18 @@ def test_commit_and_push_with_updated_http_mock(setup_http_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ - ( - [ - mocks.Fork(), - mocks.Commit(), - mocks.Push(), - ] - ) + [ + mocks.Fork(), + mocks.Commit(), + mocks.Push(), + ] ], 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") @@ -102,7 +81,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 ( @@ -124,8 +103,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") @@ -145,7 +124,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 ( @@ -166,8 +145,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") @@ -182,14 +161,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 @@ -200,7 +179,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()]), ( @@ -212,10 +191,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 @@ -225,7 +204,7 @@ def test_flexible_parametrization(user_email, setup_api_mocks): @pytest.mark.parametrize( - "setup_api_mocks", + "setup_http_mocks", [ ( [ @@ -236,8 +215,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 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