diff --git a/examples/.ruff.toml b/examples/.ruff.toml index 72322dfb1..7e5b439eb 100644 --- a/examples/.ruff.toml +++ b/examples/.ruff.toml @@ -9,5 +9,6 @@ ignore = [ [lint.per-file-ignores] "tests/**.py" = [ - "INP001", # Forbid implicit namespaces + "INP001", # Forbid implicit namespaces + "PLR2004", # Forbid magic values ] diff --git a/examples/tests/v3/provider_server.py b/examples/tests/v3/provider_server.py index 2b427524b..23ca0e2ad 100644 --- a/examples/tests/v3/provider_server.py +++ b/examples/tests/v3/provider_server.py @@ -231,7 +231,7 @@ def redirect() -> NoReturn: if __name__ == "__main__": import sys - if len(sys.argv) < 5: # noqa: PLR2004 + if len(sys.argv) < 5: sys.stderr.write( f"Usage: {sys.argv[0]} " f" " diff --git a/examples/tests/v3/test_00_consumer.py b/examples/tests/v3/test_00_consumer.py new file mode 100644 index 000000000..634ce7ffa --- /dev/null +++ b/examples/tests/v3/test_00_consumer.py @@ -0,0 +1,133 @@ +""" +HTTP consumer test using Pact Python v3. + +This module demonstrates how to write a consumer test using Pact Python's +upcoming version 3. Pact, being a consumer-driven testing tool, requires that +the consumer define the expected interactions with the provider. + +In this example, the consumer defined in `src/consumer.py` is tested against a +mock provider. The mock provider is set up by Pact and is used to ensure that +the consumer is making the expected requests to the provider. Once these +interactions are validated, the contracts can be published to a Pact Broker +where they can be re-run against the provider to ensure that the provider is +compliant with the contract. + +A good source for understanding the consumer tests is the [Pact Consumer Test +section](https://docs.pact.io/5-minute-getting-started-guide#scope-of-a-consumer-pact-test) +of the Pact documentation. +""" + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Generator + +import pytest +import requests + +from pact.v3 import Pact + + +@pytest.fixture +def pact() -> Generator[Pact, None, None]: + """ + Set up the Pact fixture. + + This fixture configures the Pact instance for the consumer test. It defines + where the pact file will be written and the consumer and provider names. + This fixture also sets the Pact specification to `V4` (the latest version). + + The use of `yield` allows this function to return the Pact instance to be + used in the test cases, and then for this function to continue running after + the test cases have completed. This is useful for writing the pact file + after the test cases have run. + + Yields: + The Pact instance for the consumer tests. + """ + pact_dir = Path(Path(__file__).parent.parent.parent / "pacts") + pact = Pact("v3_http_consumer", "v3_http_provider") + yield pact.with_specification("V4") + pact.write_file(pact_dir, overwrite=True) + + +def test_get_existing_user(pact: Pact) -> None: + """ + Retrieve an existing user's details. + + This test defines the expected interaction for a GET request to retrieve + user information. It sets up the expected request and response from the + provider and verifies that the response status code is 200. + + When setting up the expected response, the consumer should only define what + it needs from the provider (as opposed to the full schema). Should the + provider later decide to add or remove fields, Pact's consumer-driven + approach will ensure that interaction is still valid. + + The use of the `given` method allows the consumer to define the state of the + provider before the interaction. In this case, the provider is in a state + where the user exists and can be retrieved. By contrast, the same HTTP + request with a different `given` state is expected to return a 404 status + code as shown in + [`test_get_non_existent_user`](#test_get_non_existent_user). + """ + expected_response_code = 200 + expected: Dict[str, Any] = { + "id": 123, + "name": "Verna Hampton", + "created_on": { + # This structure is using the Integration JSON format as described + # in the link below. The preview of V3 currently does not have + # built-in support for matchers and generators, though this is on + # the roadmap and will be available before the final release. + # + # + "pact:matcher:type": "regex", + "regex": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}(Z|(\+|-)\d{2}:\d{2})", + "value": datetime.now(tz=timezone.utc).isoformat(), + }, + } + ( + pact.upon_receiving("a request for user information") + .given("user exists") + .with_request(method="GET", path="/users/123") + .will_respond_with(200) + .with_body(json.dumps(expected)) + ) + + with pact.serve() as srv: + response = requests.get(f"{srv.url}/users/123", timeout=5) + + assert response.status_code == expected_response_code + assert expected["name"] == "Verna Hampton" + datetime.fromisoformat(expected["created_on"]["value"]) + + +def test_get_non_existent_user(pact: Pact) -> None: + """ + Test the GET request for retrieving user information. + + This test defines the expected interaction for a GET request to retrieve + user information when that user does not exist in the provider's database. + It is the counterpart to the + [`test_get_existing_user`](#test_get_existing_user) and showcases how the + same request can have different responses based on the provider's state. + + It is up to the specific use case to determine whether negative scenarios + should be tested, and to what extent. Certain common negative scenarios + include testing for non-existent resources, unauthorized access attempts may + be useful to ensure that the consumer handles these cases correctly; but it + is generally infeasible to test all possible negative scenarios. + """ + expected_response_code = 404 + ( + pact.upon_receiving("a request for user information") + .given("user doesn't exists") + .with_request(method="GET", path="/users/2") + .will_respond_with(404) + ) + + with pact.serve() as srv: + response = requests.get(f"{srv.url}/users/2", timeout=5) + + assert response.status_code == expected_response_code diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py new file mode 100644 index 000000000..f77cc071f --- /dev/null +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -0,0 +1,259 @@ +""" +Test the FastAPI provider with Pact. + +This module demonstrates how to write a provider test using Pact Python's +upcoming version 3. Pact, being a consumer-driven testing tool, requires that +the provider respond to the requests defined by the consumer. The consumer +defines the expected interactions with the provider, and the provider is +expected to respond with the expected responses. + +This module tests the FastAPI provider defined in `src/fastapi.py` against the +mock consumer. The mock consumer is set up by Pact and will replay the requests +defined by the consumers. Pact will then validate that the provider responds +with the expected responses. + +The provider will be expected to be in a given state in order to respond to +certain requests. For example, when fetching a user's information, the provider +will need to have a user with the given ID in the database. In order to avoid +side effects, the provider's database calls are mocked out using functionalities +from `unittest.mock`. + +In order to set the provider into the correct state, this test module defines an +additional endpoint on the provider, in this case `/_pact/callback`. Calls to +this endpoint mock the relevant database calls to set the provider into the +correct state. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from multiprocessing import Process +from typing import TYPE_CHECKING, Callable, Dict, Literal +from unittest.mock import MagicMock + +import uvicorn +from yarl import URL + +from examples.src.fastapi import app +from pact.v3 import Verifier + +PROVIDER_URL = URL("http://localhost:8000") + + +@app.post("/_pact/callback") +async def mock_pact_provider_states( + action: Literal["setup", "teardown"], + state: str, +) -> Dict[Literal["result"], str]: + """ + Handler for the provider state callback. + + For Pact to be able to correctly tests compliance with the contract, the + internal state of the provider needs to be set up correctly. For example, if + the consumer expects a user to exist in the database, the provider needs to + have a user with the given ID in the database. + + Naïvely, this can be achieved by setting up the database with the correct + data for the test, but this can be slow and error-prone, and requires + standing up additional infrastructure. The alternative showcased here is to + mock the relevant calls to the database so as to avoid any side effects. The + `unittest.mock` library is used to achieve this as part of the `setup` + action. + + The added benefit of using this approach is that the mock can subsequently + be inspected to ensure that the correct calls were made to the database. For + example, asserting that the correct user ID was retrieved from the database. + These checks are performed as part of the `teardown` action. This action can + also be used to reset the mock, or in the case were a real database is used, + to clean up any side effects. + + Args: + action: + One of `setup` or `teardown`. Determines whether the provider state + should be set up or torn down. + + state: + The name of the state to set up or tear down. + + Returns: + A dictionary containing the result of the action. + """ + mapping: dict[str, dict[str, Callable[[], None]]] = {} + mapping["setup"] = { + "user doesn't exists": mock_user_doesnt_exist, + "user exists": mock_user_exists, + } + mapping["teardown"] = { + "user doesn't exists": verify_user_doesnt_exist_mock, + "user exists": verify_user_exists_mock, + } + + mapping[action][state]() + return {"result": f"{action} {state} completed"} + + +def run_server() -> None: + """ + Run the FastAPI server. + + This function is required to run the FastAPI server in a separate process. A + lambda cannot be used as the target of a `multiprocessing.Process` as it + cannot be pickled. + """ + host = PROVIDER_URL.host if PROVIDER_URL.host else "localhost" + port = PROVIDER_URL.port if PROVIDER_URL.port else 8000 + uvicorn.run(app, host=host, port=port) + + +def test_provider() -> None: + """ + Test the FastAPI provider with Pact. + + This function performs all of the provider testing. It runs as follows: + + 1. The FastAPI server is started in a separate process. A small wait time + is added to allow the server to start up before the tests begin. + 2. The Verifier is created and configured. + + 1. The `set_info` method tells Pact the names of provider to be tested. + Pact will automatically discover all the consumers that have + contracts with this provider. + + The `url` parameter is used to specify the base URL of the provider + against which the tests will be run. + 2. The `add_source` method adds the directory where the pact files are + stored. In a more typical setup, this would in fact be a Pact Broker + URL. + 3. The `set_state` method defines the endpoint on the provider that + will be called to set the provider into the correct state before the + tests begin. At present, this is the only way to set the provider + into the correct state; however, future version of Pact Python + intend to provide a more Pythonic way to do this. + + 3. The `verify` method is called to run the tests. This will run all the + tests defined in the pact files and verify that the provider responds + correctly to each request. More specifically, for each interaction, it + will perform the following steps: + + 1. The provider state(s) are by sending a POST request to the + provider's `_pact/callback` endpoint. + 2. Pact impersonates the consumer by sending a request to the provider. + 3. The provider handles the request and sends a response back to Pact. + 4. Pact validates the response against the expected response defined in + the pact file. + 5. If `teardown` is set to `True` for `set_state`, Pact will send a + `teardown` action to the provider to reset the provider state(s). + + Pact will output the results of the tests to the console and verify that the + provider is compliant with the contract. If the provider is not compliant, + the tests will fail and the output will show which interactions failed and + why. + """ + proc = Process(target=run_server, daemon=True) + proc.start() + time.sleep(2) + verifier = Verifier().set_info("v3_http_provider", url=PROVIDER_URL) + verifier.add_source("examples/pacts/v3_http_consumer-v3_http_provider.json") + verifier.set_state( + PROVIDER_URL / "_pact" / "callback", + teardown=True, + ) + verifier.verify() + + proc.terminate() + + +def mock_user_doesnt_exist() -> None: + """ + Mock the database for the user doesn't exist state. + """ + import examples.src.fastapi + + mock_db = MagicMock() + mock_db.get.return_value = None + examples.src.fastapi.FAKE_DB = mock_db + + +def mock_user_exists() -> None: + """ + Mock the database for the user exists state. + + You may notice that the return value here differs from the consumer's + expected response. This is because the consumer's expected response is + guided by what the consumer uses. + + By using consumer-driven contracts and testing the provider against the + consumer's contract, we can ensure that the provider is what the consumer + needs. This allows the provider to safely evolve their API (by both adding + and removing fields) without fear of breaking the interactions with the + consumers. + """ + import examples.src.fastapi + + mock_db = MagicMock() + mock_db.get.return_value = { + "id": 123, + "name": "Verna Hampton", + "created_on": datetime.now(tz=timezone.utc).isoformat(), + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + } + examples.src.fastapi.FAKE_DB = mock_db + + +def verify_user_doesnt_exist_mock() -> None: + """ + Verify the mock calls for the 'user doesn't exist' state. + + This function checks that the mock for `FAKE_DB.get` was called, + verifies that it returned `None`, + and ensures that it was called with an integer argument. + It then resets the mock for future tests. + + Returns: + str: A message indicating that the 'user doesn't exist' mock has been verified. + """ + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.get.assert_called_once() + + args, kwargs = examples.src.fastapi.FAKE_DB.get.call_args + + assert len(args) == 1 + assert isinstance(args[0], int) + assert kwargs == {} + + examples.src.fastapi.FAKE_DB.reset_mock() + + +def verify_user_exists_mock() -> None: + """ + Verify the mock calls for the 'user exists' state. + + This function checks that the mock for `FAKE_DB.get` was called, + verifies that it returned the expected user data, + and ensures that it was called with the integer argument `1`. + It then resets the mock for future tests. + + Returns: + str: A message indicating that the 'user exists' mock has been verified. + """ + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.get.assert_called_once() + + args, kwargs = examples.src.fastapi.FAKE_DB.get.call_args + + assert len(args) == 1 + assert isinstance(args[0], int) + assert kwargs == {} + + examples.src.fastapi.FAKE_DB.reset_mock() diff --git a/examples/tests/v3/test_01_message_consumer.py b/examples/tests/v3/test_02_message_consumer.py similarity index 100% rename from examples/tests/v3/test_01_message_consumer.py rename to examples/tests/v3/test_02_message_consumer.py diff --git a/examples/tests/v3/test_02_message_provider.py b/examples/tests/v3/test_03_message_provider.py similarity index 100% rename from examples/tests/v3/test_02_message_provider.py rename to examples/tests/v3/test_03_message_provider.py