-
Notifications
You must be signed in to change notification settings - Fork 137
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Also rename the files to ensure they mirror the v2 examples more closely. Signed-off-by: JP-Ellis <josh@jpellis.me>
- Loading branch information
Showing
8 changed files
with
383 additions
and
322 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
""" | ||
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) | ||
|
||
|
||
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": 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" | ||
|
||
|
||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,258 @@ | ||
""" | ||
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 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") | ||
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": "2024-08-29T04:53:07.337793+00:00", | ||
"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() |
File renamed without changes.
File renamed without changes.
Oops, something went wrong.