Skip to content

Commit

Permalink
docs: update example docs
Browse files Browse the repository at this point in the history
Also rename the files to ensure they mirror the v2 examples more
closely.

Signed-off-by: JP-Ellis <josh@jpellis.me>
  • Loading branch information
JP-Ellis committed Aug 30, 2024
1 parent 8b142b4 commit 2b835ec
Show file tree
Hide file tree
Showing 8 changed files with 383 additions and 322 deletions.
3 changes: 2 additions & 1 deletion examples/.ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ ignore = [

[lint.per-file-ignores]
"tests/**.py" = [
"INP001", # Forbid implicit namespaces
"INP001", # Forbid implicit namespaces
"PLR2004", # Forbid magic values
]
2 changes: 1 addition & 1 deletion examples/tests/v3/provider_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]} <state_provider_module> <state_provider_function> "
f"<handler_module> <handler_function>"
Expand Down
122 changes: 122 additions & 0 deletions examples/tests/v3/test_00_consumer.py
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
258 changes: 258 additions & 0 deletions examples/tests/v3/test_01_fastapi_provider.py
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()
Loading

0 comments on commit 2b835ec

Please sign in to comment.