From 75edcbb4eb15cca859969a00ec2d76ba5982e655 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Tue, 27 Aug 2024 13:33:04 +0530 Subject: [PATCH 1/2] feat(examples): add post and delete Extend the existing examples to showcase both HTTP POST and DELETE requests, and how they are handled in Pact. Specifically showcasing how the verifying can ensure that any side-effects have taken place. Co-authored-by: Amit Singh Signed-off-by: JP-Ellis --- .pre-commit-config.yaml | 2 +- examples/.ruff.toml | 1 + examples/src/consumer.py | 43 +++++++- examples/src/fastapi.py | 56 ++++++++++- examples/src/flask.py | 32 +++++- examples/tests/test_00_consumer.py | 82 ++++++++++++++- examples/tests/test_01_provider_fastapi.py | 62 +++++++++++- examples/tests/test_01_provider_flask.py | 57 ++++++++++- examples/tests/v3/test_00_consumer.py | 64 ++++++++++++ examples/tests/v3/test_01_fastapi_provider.py | 99 +++++++++++++++++++ 10 files changed, 483 insertions(+), 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b961ce17f4..def7e72229 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -81,5 +81,5 @@ repos: entry: hatch run mypy language: system types: [python] - exclude: ^(src/pact|tests)/(?!v3/).*\.py$ + exclude: ^(src/pact|tests|examples/tests)/(?!v3/).*\.py$ stages: [pre-push] diff --git a/examples/.ruff.toml b/examples/.ruff.toml index 7e5b439eb5..5222985a35 100644 --- a/examples/.ruff.toml +++ b/examples/.ruff.toml @@ -5,6 +5,7 @@ ignore = [ "S101", # Forbid assert statements "D103", # Require docstring in public function "D104", # Require docstring in public package + "PLR2004" # Forbid Magic Numbers ] [lint.per-file-ignores] diff --git a/examples/src/consumer.py b/examples/src/consumer.py index fa260d214d..b2815d2972 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Tuple import requests @@ -102,3 +102,44 @@ def get_user(self, user_id: int) -> User: name=data["name"], created_on=datetime.fromisoformat(data["created_on"]), ) + + def create_user( + self, user: Dict[str, Any], header: Dict[str, str] + ) -> Tuple[int, User]: + """ + Create a new user on the server. + + Args: + user: The user data to create. + header: The headers to send with the request. + + Returns: + The user data including the ID assigned by the server; Error if user exists. + """ + uri = f"{self.base_uri}/users/" + response = requests.post(uri, headers=header, json=user, timeout=5) + response.raise_for_status() + data: Dict[str, Any] = response.json() + return ( + response.status_code, + User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), + ), + ) + + def delete_user(self, user_id: int) -> int: + """ + Delete a user by ID from the server. + + Args: + user_id: The ID of the user to delete. + + Returns: + The response status code. + """ + uri = f"{self.base_uri}/users/{user_id}" + response = requests.delete(uri, timeout=5) + response.raise_for_status() + return response.status_code diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index b25d3c1a8e..e522b08f85 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -19,12 +19,30 @@ from __future__ import annotations +import logging from typing import Any, Dict -from fastapi import FastAPI +from pydantic import BaseModel + +from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse app = FastAPI() +logger = logging.getLogger(__name__) + + +class User(BaseModel): + """ + User data class. + + This class is used to represent a user in the application. It is used to + validate the incoming data and to dump the data to a dictionary. + """ + + id: int | None = None + name: str + email: str + """ As this is a simple example, we'll use a simple dict to represent a database. @@ -52,3 +70,39 @@ async def get_user_by_id(uid: int) -> JSONResponse: if not user: return JSONResponse(status_code=404, content={"error": "User not found"}) return JSONResponse(status_code=200, content=user) + + +@app.post("/users/") +async def create_new_user(user: User) -> JSONResponse: + """ + Create a new user . + + Args: + user: The user data to create + + Returns: + The status code 200 and user data if successfully created, HTTP 404 if not + """ + if user.id is not None: + raise HTTPException(status_code=400, detail="ID should not be provided.") + new_uid = len(FAKE_DB) + FAKE_DB[new_uid] = user.model_dump() + + return JSONResponse(status_code=200, content=FAKE_DB[new_uid]) + + +@app.delete("/users/{user_id}", status_code=204) +async def delete_user(user_id: int): # noqa: ANN201 + """ + Delete an existing user . + + Args: + user_id: The ID of the user to delete + + Returns: + The status code 204, HTTP 404 if not + """ + if user_id not in FAKE_DB: + raise HTTPException(status_code=404, detail="User not found") + + del FAKE_DB[user_id] diff --git a/examples/src/flask.py b/examples/src/flask.py index 31d614042a..7fa32cdeae 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -19,12 +19,14 @@ from __future__ import annotations -from typing import Any, Dict, Union +import logging +from typing import Any, Dict, Tuple, Union -from flask import Flask +from flask import Flask, Response, abort, jsonify, request -app = Flask(__name__) +logger = logging.getLogger(__name__) +app = Flask(__name__) """ As this is a simple example, we'll use a simple dict to represent a database. This would be replaced with a real database in a real application. @@ -47,7 +49,29 @@ def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int] Returns: The user data if found, HTTP 404 if not """ - user = FAKE_DB.get(uid) + user = FAKE_DB.get(int(uid)) if not user: return {"error": "User not found"}, 404 return user + + +@app.route("/users/", methods=["POST"]) +def create_user() -> Tuple[Response, int]: + if request.json is None: + abort(400, description="Invalid JSON data") + + data: Dict[str, Any] = request.json + new_uid: int = len(FAKE_DB) + if new_uid in FAKE_DB: + abort(400, description="User already exists") + + FAKE_DB[new_uid] = {"id": new_uid, "name": data["name"], "email": data["email"]} + return jsonify(FAKE_DB[new_uid]), 200 + + +@app.route("/users/", methods=["DELETE"]) +def delete_user(user_id: int) -> Tuple[str, int]: + if user_id not in FAKE_DB: + abort(404, description="User not found") + del FAKE_DB[user_id] + return "", 204 # No Content status code diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py index 9076785081..4c5a86dfdc 100644 --- a/examples/tests/test_00_consumer.py +++ b/examples/tests/test_00_consumer.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import TYPE_CHECKING, Any, Dict, Generator @@ -24,18 +25,28 @@ from yarl import URL from examples.src.consumer import User, UserConsumer -from pact import Consumer, Format, Like, Provider +from pact import Consumer, Format, Like, Provider # type: ignore[attr-defined] if TYPE_CHECKING: from pathlib import Path - from pact.pact import Pact + from pact.pact import Pact # type: ignore[import-untyped] -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) MOCK_URL = URL("http://localhost:8080") +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + from pact.v3 import ffi + + ffi.log_to_stderr("INFO") + + @pytest.fixture def user_consumer() -> UserConsumer: """ @@ -78,7 +89,7 @@ def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]: pact = consumer.has_pact_with( Provider("UserProvider"), pact_dir=pact_dir, - publish_to_broker=True, + publish_to_broker=False, # Mock service configuration host_name=MOCK_URL.host, port=MOCK_URL.port, @@ -142,3 +153,66 @@ def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None: assert excinfo.value.response is not None assert excinfo.value.response.status_code == HTTPStatus.NOT_FOUND pact.verify() + + +def test_post_request_to_create_user(pact: Pact, user_consumer: UserConsumer) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create + a new user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected: Dict[str, Any] = { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": Format().iso_8601_datetime(), + } + header = {"Content-Type": "application/json"} + payload: dict[str, str] = { + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + } + expected_response_code: int = 200 + + ( + pact.given("create user 124") + .upon_receiving("A request to create a new user") + .with_request(method="POST", path="/users/", headers=header, body=payload) + .will_respond_with(status=200, headers=header, body=Like(expected)) + ) + + with pact: + response = user_consumer.create_user(user=payload, header=header) + assert response[0] == expected_response_code + assert response[1].id == 124 + assert response[1].name == "Jane Doe" + + pact.verify() + + +def test_delete_request_to_delete_user(pact: Pact, user_consumer: UserConsumer) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete + a user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected_response_code: int = 204 + ( + pact.given("delete the user 124") + .upon_receiving("a request for deleting user") + .with_request(method="DELETE", path="/users/124") + .will_respond_with(204) + ) + + with pact: + response_status_code = user_consumer.delete_user(124) + assert response_status_code == expected_response_code + + pact.verify() diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index a95b5b5f8d..c9fd23cebf 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -25,6 +25,7 @@ from __future__ import annotations import time +from datetime import UTC, datetime, timedelta from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -35,7 +36,7 @@ from yarl import URL from examples.src.fastapi import app -from pact import Verifier +from pact import Verifier # type: ignore[import-untyped] PROVIDER_URL = URL("http://localhost:8080") @@ -68,6 +69,8 @@ async def mock_pact_provider_states( mapping = { "user 123 doesn't exist": mock_user_123_doesnt_exist, "user 123 exists": mock_user_123_exists, + "create user 124": mock_post_request_to_create_user, + "delete the user 124": mock_delete_request_to_delete_user, } return {"result": mapping[state.state]()} @@ -127,13 +130,68 @@ def mock_user_123_exists() -> None: examples.src.fastapi.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", - "created_on": "2016-12-15T20:16:01", + "created_on": datetime.now(tz=UTC).isoformat(), "ip_address": "10.1.2.3", "hobbies": ["hiking", "swimming"], "admin": False, } +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.fastapi + + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.__len__.return_value = 124 + examples.src.fastapi.FAKE_DB.__setitem__.return_value = None + examples.src.fastapi.FAKE_DB.__getitem__.return_value = { + "id": 124, + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "email": "jane@example.com", + "name": "Jane Doe", + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + } + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.fastapi + + db_values = { + 123: { + "id": 123, + "name": "Verna Hampton", + "email": "verna@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + examples.src.fastapi.FAKE_DB = MagicMock() + examples.src.fastapi.FAKE_DB.__delitem__.side_effect = ( + lambda key: db_values.__delitem__(key) + ) + examples.src.fastapi.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] + examples.src.fastapi.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + + def test_against_broker(broker: URL, verifier: Verifier) -> None: """ Test the provider against the broker. diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 58cb024585..2ebd0c80a9 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -25,6 +25,7 @@ from __future__ import annotations import time +from datetime import UTC, datetime, timedelta from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -34,7 +35,7 @@ from examples.src.flask import app from flask import request -from pact import Verifier +from pact import Verifier # type: ignore[import-untyped] PROVIDER_URL = URL("http://localhost:8080") @@ -58,6 +59,8 @@ async def mock_pact_provider_states() -> Dict[str, Union[str, None]]: mapping = { "user 123 doesn't exist": mock_user_123_doesnt_exist, "user 123 exists": mock_user_123_exists, + "create user 124": mock_post_request_to_create_user, + "delete the user 124": mock_delete_request_to_delete_user, } return {"result": mapping[request.json["state"]]()} # type: ignore[index] @@ -115,13 +118,63 @@ def mock_user_123_exists() -> None: examples.src.flask.FAKE_DB.get.return_value = { "id": 123, "name": "Verna Hampton", - "created_on": "2016-12-15T20:16:01", + "created_on": datetime.now(tz=UTC).isoformat(), "ip_address": "10.1.2.3", "hobbies": ["hiking", "swimming"], "admin": False, } +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.flask + + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.__len__.return_value = 124 + examples.src.flask.FAKE_DB.__setitem__.return_value = None + examples.src.flask.FAKE_DB.__getitem__.return_value = { + "id": 124, + "created_on": (datetime.now(tz=UTC) - timedelta(days=261)).isoformat(), + "email": "jane@example.com", + "name": "Jane Doe", + } + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.flask + + db_values: dict[int, dict[str, Any]] = { + 123: { + "id": 123, + "name": "Verna Hampton", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + "ip_address": "10.1.2.3", + "hobbies": ["hiking", "swimming"], + "admin": False, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + examples.src.flask.FAKE_DB = MagicMock() + examples.src.flask.FAKE_DB.__delitem__.side_effect = ( + lambda key: db_values.__delitem__(key) + ) + examples.src.flask.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] + examples.src.flask.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + + def test_against_broker(broker: URL, verifier: Verifier) -> None: """ Test the provider against the broker. diff --git a/examples/tests/v3/test_00_consumer.py b/examples/tests/v3/test_00_consumer.py index 634ce7ffae..c69a53c3a5 100644 --- a/examples/tests/v3/test_00_consumer.py +++ b/examples/tests/v3/test_00_consumer.py @@ -131,3 +131,67 @@ def test_get_non_existent_user(pact: Pact) -> None: response = requests.get(f"{srv.url}/users/2", timeout=5) assert response.status_code == expected_response_code + + +def test_post_request_to_create_user(pact: Pact) -> None: + """ + Test the POST request for creating a new user. + + This test defines the expected interaction for a POST request to create + a new user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected: Dict[str, Any] = { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + } + header = {"Content-Type": "application/json"} + body = {"name": "Jane Doe", "email": "jane@example.com"} + expected_response_code: int = 200 + + ( + pact.upon_receiving("a request to create a new user") + .given("the specified user doesn't exist") + .with_request(method="POST", path="/users/") + .with_body(json.dumps(body)) + .with_header("Content-Type", "application/json") + .will_respond_with(status=200) + .with_body(content_type="application/json", body=json.dumps(expected)) + ) + + with pact.serve() as srv: + response = requests.post( + f"{srv.url}/users/", headers=header, json=body, timeout=5 + ) + + assert response.status_code == expected_response_code + assert response.json() == { + "id": 124, + "name": "Jane Doe", + "email": "jane@example.com", + } + + +def test_delete_request_to_delete_user(pact: Pact) -> None: + """ + Test the DELETE request for deleting a user. + + This test defines the expected interaction for a DELETE request to delete + a user. It sets up the expected request and response from the provider, + including the request body and headers, and verifies that the response + status code is 200 and the response body matches the expected user data. + """ + expected_response_code: int = 204 + ( + pact.upon_receiving("a request for deleting user") + .given("user is present in DB") + .with_request(method="DELETE", path="/users/124") + .will_respond_with(204) + ) + + with pact.serve() as srv: + response = requests.delete(f"{srv.url}/users/124", 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 index f77cc071f5..14392ad9c8 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -83,10 +83,14 @@ async def mock_pact_provider_states( mapping["setup"] = { "user doesn't exists": mock_user_doesnt_exist, "user exists": mock_user_exists, + "the specified user doesn't exist": mock_post_request_to_create_user, + "user is present in DB": mock_delete_request_to_delete_user, } mapping["teardown"] = { "user doesn't exists": verify_user_doesnt_exist_mock, "user exists": verify_user_exists_mock, + "the specified user doesn't exist": verify_mock_post_request_to_create_user, + "user is present in DB": verify_mock_delete_request_to_delete_user, } mapping[action][state]() @@ -203,6 +207,56 @@ def mock_user_exists() -> None: examples.src.fastapi.FAKE_DB = mock_db +def mock_post_request_to_create_user() -> None: + """ + Mock the database for the post request to create a user. + """ + import examples.src.fastapi + + mock_db = MagicMock() + mock_db.__len__.return_value = 124 + mock_db.__setitem__.return_value = None + mock_db.__getitem__.return_value = { + "id": 124, + "created_on": "2024-09-06T05:07:06.745719+00:00", + "email": "jane@example.com", + "name": "Jane Doe", + } + examples.src.fastapi.FAKE_DB = mock_db + + +def mock_delete_request_to_delete_user() -> None: + """ + Mock the database for the delete request to delete a user. + """ + import examples.src.fastapi + + db_values = { + 123: { + "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, + }, + 124: { + "id": 124, + "name": "Jane Doe", + "created_on": "2024-08-29T04:53:07.337793+00:00", + "ip_address": "10.1.2.5", + "hobbies": ["running", "dancing"], + "admin": False, + }, + } + + mock_db = MagicMock() + mock_db.__delitem__.side_effect = lambda key: db_values.__delitem__(key) + mock_db.__getitem__.side_effect = lambda key: db_values[key] + mock_db.__contains__.side_effect = lambda key: key in db_values + 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. @@ -257,3 +311,48 @@ def verify_user_exists_mock() -> None: assert kwargs == {} examples.src.fastapi.FAKE_DB.reset_mock() + + +def verify_mock_post_request_to_create_user() -> None: + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.__getitem__.assert_called() + + expected_return = { + "id": 124, + "created_on": "2024-09-06T05:07:06.745719+00:00", + "email": "jane@example.com", + "name": "Jane Doe", + } + + examples.src.fastapi.FAKE_DB.__len__.assert_called() + assert ( + examples.src.fastapi.FAKE_DB.__getitem__.return_value == expected_return + ), "Unexpected return value from __getitem__()" + + args, _ = examples.src.fastapi.FAKE_DB.__getitem__.call_args + assert isinstance( + args[0], int + ), f"Expected get() to be called with an integer, but got {type(args[0])}" + assert args[0] == 124, f"Expected get(124), but got get({args[0]})" + + examples.src.fastapi.FAKE_DB.reset_mock() + + +def verify_mock_delete_request_to_delete_user() -> None: + import examples.src.fastapi + + if TYPE_CHECKING: + examples.src.fastapi.FAKE_DB = MagicMock() + + examples.src.fastapi.FAKE_DB.__delitem__.assert_called() + + args, _ = examples.src.fastapi.FAKE_DB.__delitem__.call_args + assert isinstance( + args[0], int + ), f"Expected __delitem__() to be called with an integer, but got {type(args[0])}" + + examples.src.fastapi.FAKE_DB.reset_mock() From 7c1f341f275b9f5af83c17cf647a68abd0ee3a0e Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 13:31:32 +1000 Subject: [PATCH 2/2] chore: refactor tests Signed-off-by: JP-Ellis --- examples/.ruff.toml | 8 +- examples/src/consumer.py | 50 +++-- examples/src/fastapi.py | 96 +++++++--- examples/src/flask.py | 107 ++++++++--- examples/tests/test_00_consumer.py | 60 +++--- examples/tests/test_01_provider_fastapi.py | 106 ++++++----- examples/tests/test_01_provider_flask.py | 107 ++++++----- examples/tests/v3/conftest.py | 15 ++ examples/tests/v3/test_00_consumer.py | 59 +++--- examples/tests/v3/test_01_fastapi_provider.py | 175 ++++++++++-------- 10 files changed, 469 insertions(+), 314 deletions(-) create mode 100644 examples/tests/v3/conftest.py diff --git a/examples/.ruff.toml b/examples/.ruff.toml index 5222985a35..6b2ed4c155 100644 --- a/examples/.ruff.toml +++ b/examples/.ruff.toml @@ -2,10 +2,10 @@ extend = "../pyproject.toml" [lint] ignore = [ - "S101", # Forbid assert statements - "D103", # Require docstring in public function - "D104", # Require docstring in public package - "PLR2004" # Forbid Magic Numbers + "S101", # Forbid assert statements + "D103", # Require docstring in public function + "D104", # Require docstring in public package + "PLR2004", # Forbid Magic Numbers ] [lint.per-file-ignores] diff --git a/examples/src/consumer.py b/examples/src/consumer.py index b2815d2972..4532979237 100644 --- a/examples/src/consumer.py +++ b/examples/src/consumer.py @@ -12,6 +12,13 @@ [`User`][examples.src.consumer.User] class and the consumer fetches a user's information from a HTTP endpoint. +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. So you will see +below that as far as this consumer is concerned, the only information needed +from the provider is the user's ID, name, and creation date. This is despite the +provider having additional fields in the response. + Note that the code in this module is agnostic of Pact. The `pact-python` dependency only appears in the tests. This is because the consumer is not concerned with Pact, only the tests are. @@ -21,7 +28,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, Tuple +from typing import Any, Dict import requests @@ -104,42 +111,45 @@ def get_user(self, user_id: int) -> User: ) def create_user( - self, user: Dict[str, Any], header: Dict[str, str] - ) -> Tuple[int, User]: + self, + *, + name: str, + ) -> User: """ Create a new user on the server. Args: - user: The user data to create. - header: The headers to send with the request. + name: The name of the user to create. Returns: - The user data including the ID assigned by the server; Error if user exists. + The user, if successfully created. + + Raises: + requests.HTTPError: If the server returns a non-200 response. """ uri = f"{self.base_uri}/users/" - response = requests.post(uri, headers=header, json=user, timeout=5) + response = requests.post(uri, json={"name": name}, timeout=5) response.raise_for_status() data: Dict[str, Any] = response.json() - return ( - response.status_code, - User( - id=data["id"], - name=data["name"], - created_on=datetime.fromisoformat(data["created_on"]), - ), + return User( + id=data["id"], + name=data["name"], + created_on=datetime.fromisoformat(data["created_on"]), ) - def delete_user(self, user_id: int) -> int: + def delete_user(self, uid: int | User) -> None: """ Delete a user by ID from the server. Args: - user_id: The ID of the user to delete. + uid: The user ID or user object to delete. - Returns: - The response status code. + Raises: + requests.HTTPError: If the server returns a non-200 response. """ - uri = f"{self.base_uri}/users/{user_id}" + if isinstance(uid, User): + uid = uid.id + + uri = f"{self.base_uri}/users/{uid}" response = requests.delete(uri, timeout=5) response.raise_for_status() - return response.status_code diff --git a/examples/src/fastapi.py b/examples/src/fastapi.py index e522b08f85..013e95f13d 100644 --- a/examples/src/fastapi.py +++ b/examples/src/fastapi.py @@ -12,6 +12,14 @@ (the consumer) and returns a response. In this example, we have a simple endpoint which returns a user's information from a (fake) database. +This also showcases how Pact tests differ from merely testing adherence to an +OpenAPI specification. The Pact tests are more concerned with the practical use +of the API, rather than the formally defined specification. The User class +defined here has additional fields which are not used by the consumer. Should +the provider later decide to add or remove fields, Pact's consumer-driven +testing will provide feedback on whether the consumer is compatible with the +provider's changes. + Note that the code in this module is agnostic of Pact. The `pact-python` dependency only appears in the tests. This is because the consumer is not concerned with Pact, only the tests are. @@ -20,28 +28,51 @@ from __future__ import annotations import logging +from dataclasses import dataclass +from datetime import UTC, datetime from typing import Any, Dict -from pydantic import BaseModel - from fastapi import FastAPI, HTTPException -from fastapi.responses import JSONResponse app = FastAPI() logger = logging.getLogger(__name__) -class User(BaseModel): - """ - User data class. - - This class is used to represent a user in the application. It is used to - validate the incoming data and to dump the data to a dictionary. - """ +@dataclass() +class User: + """User data class.""" - id: int | None = None + id: int name: str - email: str + created_on: datetime + email: str | None + ip_address: str | None + hobbies: list[str] + admin: bool + + def __post_init__(self) -> None: + """ + Validate the User data. + + This performs the following checks: + + - The name cannot be empty + - The id must be a positive integer + + Raises: + ValueError: If any of the above checks fail. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id < 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """Return the user's name.""" + return f"User({self.id}:{self.name})" """ @@ -52,11 +83,11 @@ class User(BaseModel): be mocked out to avoid the need for a real database. An example of this can be found in the [test suite][examples.tests.test_01_provider_fastapi]. """ -FAKE_DB: Dict[int, Dict[str, Any]] = {} +FAKE_DB: Dict[int, User] = {} @app.get("/users/{uid}") -async def get_user_by_id(uid: int) -> JSONResponse: +async def get_user_by_id(uid: int) -> User: """ Fetch a user by their ID. @@ -68,12 +99,12 @@ async def get_user_by_id(uid: int) -> JSONResponse: """ user = FAKE_DB.get(uid) if not user: - return JSONResponse(status_code=404, content={"error": "User not found"}) - return JSONResponse(status_code=200, content=user) + raise HTTPException(status_code=404, detail="User not found") + return user @app.post("/users/") -async def create_new_user(user: User) -> JSONResponse: +async def create_new_user(user: dict[str, Any]) -> User: """ Create a new user . @@ -83,26 +114,33 @@ async def create_new_user(user: User) -> JSONResponse: Returns: The status code 200 and user data if successfully created, HTTP 404 if not """ - if user.id is not None: + if "id" in user: raise HTTPException(status_code=400, detail="ID should not be provided.") - new_uid = len(FAKE_DB) - FAKE_DB[new_uid] = user.model_dump() - - return JSONResponse(status_code=200, content=FAKE_DB[new_uid]) - - -@app.delete("/users/{user_id}", status_code=204) -async def delete_user(user_id: int): # noqa: ANN201 + uid = len(FAKE_DB) + FAKE_DB[uid] = User( + id=uid, + name=user["name"], + created_on=datetime.now(tz=UTC), + email=user.get("email"), + ip_address=user.get("ip_address"), + hobbies=user.get("hobbies", []), + admin=user.get("admin", False), + ) + return FAKE_DB[uid] + + +@app.delete("/users/{uid}", status_code=204) +async def delete_user(uid: int): # noqa: ANN201 """ Delete an existing user . Args: - user_id: The ID of the user to delete + uid: The ID of the user to delete Returns: The status code 204, HTTP 404 if not """ - if user_id not in FAKE_DB: + if uid not in FAKE_DB: raise HTTPException(status_code=404, detail="User not found") - del FAKE_DB[user_id] + del FAKE_DB[uid] diff --git a/examples/src/flask.py b/examples/src/flask.py index 7fa32cdeae..98107e3c1b 100644 --- a/examples/src/flask.py +++ b/examples/src/flask.py @@ -20,13 +20,67 @@ from __future__ import annotations import logging -from typing import Any, Dict, Tuple, Union +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Dict, Tuple from flask import Flask, Response, abort, jsonify, request logger = logging.getLogger(__name__) - app = Flask(__name__) + + +@dataclass() +class User: + """User data class.""" + + id: int + name: str + created_on: datetime + email: str | None + ip_address: str | None + hobbies: list[str] + admin: bool + + def __post_init__(self) -> None: + """ + Validate the User data. + + This performs the following checks: + + - The name cannot be empty + - The id must be a positive integer + + Raises: + ValueError: If any of the above checks fail. + """ + if not self.name: + msg = "User must have a name" + raise ValueError(msg) + + if self.id < 0: + msg = "User ID must be a positive integer" + raise ValueError(msg) + + def __repr__(self) -> str: + """Return the user's name.""" + return f"User({self.id}:{self.name})" + + def dict(self) -> dict[str, Any]: + """ + Return the user's data as a dict. + """ + return { + "id": self.id, + "name": self.name, + "created_on": self.created_on.isoformat(), + "email": self.email, + "ip_address": self.ip_address, + "hobbies": self.hobbies, + "admin": self.admin, + } + + """ As this is a simple example, we'll use a simple dict to represent a database. This would be replaced with a real database in a real application. @@ -35,11 +89,11 @@ be mocked out to avoid the need for a real database. An example of this can be found in the [test suite][examples.tests.test_01_provider_flask]. """ -FAKE_DB: Dict[int, Dict[str, Any]] = {} +FAKE_DB: Dict[int, User] = {} -@app.route("/users/") -def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int]]: +@app.route("/users/") +def get_user_by_id(uid: int) -> Response | Tuple[Response, int]: """ Fetch a user by their ID. @@ -49,29 +103,34 @@ def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int] Returns: The user data if found, HTTP 404 if not """ - user = FAKE_DB.get(int(uid)) + user = FAKE_DB.get(uid) if not user: - return {"error": "User not found"}, 404 - return user + return jsonify({"detail": "User not found"}), 404 + return jsonify(user.dict()) @app.route("/users/", methods=["POST"]) -def create_user() -> Tuple[Response, int]: +def create_user() -> Response: if request.json is None: abort(400, description="Invalid JSON data") - data: Dict[str, Any] = request.json - new_uid: int = len(FAKE_DB) - if new_uid in FAKE_DB: - abort(400, description="User already exists") - - FAKE_DB[new_uid] = {"id": new_uid, "name": data["name"], "email": data["email"]} - return jsonify(FAKE_DB[new_uid]), 200 - - -@app.route("/users/", methods=["DELETE"]) -def delete_user(user_id: int) -> Tuple[str, int]: - if user_id not in FAKE_DB: - abort(404, description="User not found") - del FAKE_DB[user_id] - return "", 204 # No Content status code + user: Dict[str, Any] = request.json + uid = len(FAKE_DB) + FAKE_DB[uid] = User( + id=uid, + name=user["name"], + created_on=datetime.now(tz=UTC), + email=user.get("email"), + ip_address=user.get("ip_address"), + hobbies=user.get("hobbies", []), + admin=user.get("admin", False), + ) + return jsonify(FAKE_DB[uid].dict()) + + +@app.route("/users/", methods=["DELETE"]) +def delete_user(uid: int) -> Tuple[str | Response, int]: + if uid not in FAKE_DB: + return jsonify({"detail": "User not found"}), 404 + del FAKE_DB[uid] + return "", 204 diff --git a/examples/tests/test_00_consumer.py b/examples/tests/test_00_consumer.py index 4c5a86dfdc..273b4405de 100644 --- a/examples/tests/test_00_consumer.py +++ b/examples/tests/test_00_consumer.py @@ -16,7 +16,6 @@ from __future__ import annotations import logging -from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import TYPE_CHECKING, Any, Dict, Generator @@ -25,28 +24,18 @@ from yarl import URL from examples.src.consumer import User, UserConsumer -from pact import Consumer, Format, Like, Provider # type: ignore[attr-defined] +from pact import Consumer, Format, Like, Provider if TYPE_CHECKING: from pathlib import Path - from pact.pact import Pact # type: ignore[import-untyped] + from pact.pact import Pact logger = logging.getLogger(__name__) MOCK_URL = URL("http://localhost:8080") -@pytest.fixture(scope="session", autouse=True) -def _setup_pact_logging() -> None: - """ - Set up logging for the pact package. - """ - from pact.v3 import ffi - - ffi.log_to_stderr("INFO") - - @pytest.fixture def user_consumer() -> UserConsumer: """ @@ -89,7 +78,7 @@ def pact(broker: URL, pact_dir: Path) -> Generator[Pact, Any, None]: pact = consumer.has_pact_with( Provider("UserProvider"), pact_dir=pact_dir, - publish_to_broker=False, + publish_to_broker=True, # Mock service configuration host_name=MOCK_URL.host, port=MOCK_URL.port, @@ -138,7 +127,7 @@ def test_get_existing_user(pact: Pact, user_consumer: UserConsumer) -> None: def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None: - expected = {"error": "User not found"} + expected = {"detail": "User not found"} ( pact.given("user 123 doesn't exist") @@ -155,7 +144,7 @@ def test_get_unknown_user(pact: Pact, user_consumer: UserConsumer) -> None: pact.verify() -def test_post_request_to_create_user(pact: Pact, user_consumer: UserConsumer) -> None: +def test_create_user(pact: Pact, user_consumer: UserConsumer) -> None: """ Test the POST request for creating a new user. @@ -164,32 +153,33 @@ def test_post_request_to_create_user(pact: Pact, user_consumer: UserConsumer) -> including the request body and headers, and verifies that the response status code is 200 and the response body matches the expected user data. """ - expected: Dict[str, Any] = { + body = {"name": "Verna Hampton"} + expected_response: Dict[str, Any] = { "id": 124, - "name": "Jane Doe", - "email": "jane@example.com", + "name": "Verna Hampton", "created_on": Format().iso_8601_datetime(), } - header = {"Content-Type": "application/json"} - payload: dict[str, str] = { - "name": "Jane Doe", - "email": "jane@example.com", - "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), - } - expected_response_code: int = 200 ( pact.given("create user 124") .upon_receiving("A request to create a new user") - .with_request(method="POST", path="/users/", headers=header, body=payload) - .will_respond_with(status=200, headers=header, body=Like(expected)) + .with_request( + method="POST", + path="/users/", + body=body, + headers={"Content-Type": "application/json"}, + ) + .will_respond_with( + status=200, + body=Like(expected_response), + ) ) with pact: - response = user_consumer.create_user(user=payload, header=header) - assert response[0] == expected_response_code - assert response[1].id == 124 - assert response[1].name == "Jane Doe" + user = user_consumer.create_user(name="Verna Hampton") + assert user.id > 0 + assert user.name == "Verna Hampton" + assert user.created_on pact.verify() @@ -203,16 +193,14 @@ def test_delete_request_to_delete_user(pact: Pact, user_consumer: UserConsumer) including the request body and headers, and verifies that the response status code is 200 and the response body matches the expected user data. """ - expected_response_code: int = 204 ( pact.given("delete the user 124") .upon_receiving("a request for deleting user") .with_request(method="DELETE", path="/users/124") - .will_respond_with(204) + .will_respond_with(status=204) ) with pact: - response_status_code = user_consumer.delete_user(124) - assert response_status_code == expected_response_code + user_consumer.delete_user(124) pact.verify() diff --git a/examples/tests/test_01_provider_fastapi.py b/examples/tests/test_01_provider_fastapi.py index c9fd23cebf..0825b1c4c3 100644 --- a/examples/tests/test_01_provider_fastapi.py +++ b/examples/tests/test_01_provider_fastapi.py @@ -25,7 +25,7 @@ from __future__ import annotations import time -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -35,7 +35,7 @@ from pydantic import BaseModel from yarl import URL -from examples.src.fastapi import app +from examples.src.fastapi import User, app from pact import Verifier # type: ignore[import-untyped] PROVIDER_URL = URL("http://localhost:8080") @@ -72,7 +72,8 @@ async def mock_pact_provider_states( "create user 124": mock_post_request_to_create_user, "delete the user 124": mock_delete_request_to_delete_user, } - return {"result": mapping[state.state]()} + mapping[state.state]() + return {"result": f"{state} set"} def run_server() -> None: @@ -126,15 +127,17 @@ def mock_user_123_exists() -> None: """ import examples.src.fastapi - examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.get.return_value = { - "id": 123, - "name": "Verna Hampton", - "created_on": datetime.now(tz=UTC).isoformat(), - "ip_address": "10.1.2.3", - "hobbies": ["hiking", "swimming"], - "admin": False, - } + mock_db = MagicMock() + mock_db.get.return_value = User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ) + examples.src.fastapi.FAKE_DB = mock_db def mock_post_request_to_create_user() -> None: @@ -143,18 +146,19 @@ def mock_post_request_to_create_user() -> None: """ import examples.src.fastapi - examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.__len__.return_value = 124 - examples.src.fastapi.FAKE_DB.__setitem__.return_value = None - examples.src.fastapi.FAKE_DB.__getitem__.return_value = { - "id": 124, - "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), - "email": "jane@example.com", - "name": "Jane Doe", - "ip_address": "10.1.2.3", - "hobbies": ["hiking", "swimming"], - "admin": False, - } + local_db: Dict[int, User] = {} + + def local_setitem(key: int, value: User) -> None: + local_db[key] = value + + def local_getitem(key: int) -> User: + return local_db[key] + + mock_db = MagicMock() + mock_db.__len__.return_value = 124 + mock_db.__setitem__.side_effect = local_setitem + mock_db.__getitem__.side_effect = local_getitem + examples.src.fastapi.FAKE_DB = mock_db def mock_delete_request_to_delete_user() -> None: @@ -163,33 +167,37 @@ def mock_delete_request_to_delete_user() -> None: """ import examples.src.fastapi - db_values = { - 123: { - "id": 123, - "name": "Verna Hampton", - "email": "verna@example.com", - "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), - "ip_address": "10.1.2.3", - "hobbies": ["hiking", "swimming"], - "admin": False, - }, - 124: { - "id": 124, - "name": "Jane Doe", - "email": "jane@example.com", - "created_on": (datetime.now(tz=UTC) - timedelta(days=152)).isoformat(), - "ip_address": "10.1.2.5", - "hobbies": ["running", "dancing"], - "admin": False, - }, + local_db = { + 123: User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ), + 124: User( + id=124, + name="Jane Doe", + email="jane@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.5", + hobbies=["running", "dancing"], + admin=False, + ), } - examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.__delitem__.side_effect = ( - lambda key: db_values.__delitem__(key) - ) - examples.src.fastapi.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] - examples.src.fastapi.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + def local_delitem(key: int) -> None: + del local_db[key] + + def local_contains(key: int) -> bool: + return key in local_db + + mock_db = MagicMock() + mock_db.__delitem__.side_effect = local_delitem + mock_db.__contains__.side_effect = local_contains + examples.src.fastapi.FAKE_DB = mock_db def test_against_broker(broker: URL, verifier: Verifier) -> None: diff --git a/examples/tests/test_01_provider_flask.py b/examples/tests/test_01_provider_flask.py index 2ebd0c80a9..a26c1bdbe3 100644 --- a/examples/tests/test_01_provider_flask.py +++ b/examples/tests/test_01_provider_flask.py @@ -25,7 +25,7 @@ from __future__ import annotations import time -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from multiprocessing import Process from typing import Any, Dict, Generator, Union from unittest.mock import MagicMock @@ -33,7 +33,7 @@ import pytest from yarl import URL -from examples.src.flask import app +from examples.src.flask import User, app from flask import request from pact import Verifier # type: ignore[import-untyped] @@ -56,13 +56,17 @@ async def mock_pact_provider_states() -> Dict[str, Union[str, None]]: endpoint is called by Pact before each test to ensure that the provider is in the correct state. """ + if request.json is None: + msg = "Request must be JSON" + raise ValueError(msg) + state: str = request.json["state"] mapping = { "user 123 doesn't exist": mock_user_123_doesnt_exist, "user 123 exists": mock_user_123_exists, "create user 124": mock_post_request_to_create_user, "delete the user 124": mock_delete_request_to_delete_user, } - return {"result": mapping[request.json["state"]]()} # type: ignore[index] + return {"result": mapping[state]()} # type: ignore[index] def run_server() -> None: @@ -73,7 +77,10 @@ def run_server() -> None: lambda cannot be used as the target of a `multiprocessing.Process` as it cannot be pickled. """ - app.run(host=PROVIDER_URL.host, port=PROVIDER_URL.port) + app.run( + host=PROVIDER_URL.host, + port=PROVIDER_URL.port, + ) @pytest.fixture(scope="module") @@ -115,14 +122,15 @@ def mock_user_123_exists() -> None: import examples.src.flask examples.src.flask.FAKE_DB = MagicMock() - examples.src.flask.FAKE_DB.get.return_value = { - "id": 123, - "name": "Verna Hampton", - "created_on": datetime.now(tz=UTC).isoformat(), - "ip_address": "10.1.2.3", - "hobbies": ["hiking", "swimming"], - "admin": False, - } + examples.src.flask.FAKE_DB.get.return_value = User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ) def mock_post_request_to_create_user() -> None: @@ -131,15 +139,19 @@ def mock_post_request_to_create_user() -> None: """ import examples.src.flask - examples.src.flask.FAKE_DB = MagicMock() - examples.src.flask.FAKE_DB.__len__.return_value = 124 - examples.src.flask.FAKE_DB.__setitem__.return_value = None - examples.src.flask.FAKE_DB.__getitem__.return_value = { - "id": 124, - "created_on": (datetime.now(tz=UTC) - timedelta(days=261)).isoformat(), - "email": "jane@example.com", - "name": "Jane Doe", - } + local_db: Dict[int, User] = {} + + def local_setitem(key: int, value: User) -> None: + local_db[key] = value + + def local_getitem(key: int) -> User: + return local_db[key] + + mock_db = MagicMock() + mock_db.__len__.return_value = 124 + mock_db.__setitem__.side_effect = local_setitem + mock_db.__getitem__.side_effect = local_getitem + examples.src.flask.FAKE_DB = mock_db def mock_delete_request_to_delete_user() -> None: @@ -148,31 +160,38 @@ def mock_delete_request_to_delete_user() -> None: """ import examples.src.flask - db_values: dict[int, dict[str, Any]] = { - 123: { - "id": 123, - "name": "Verna Hampton", - "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), - "ip_address": "10.1.2.3", - "hobbies": ["hiking", "swimming"], - "admin": False, - }, - 124: { - "id": 124, - "name": "Jane Doe", - "created_on": (datetime.now(tz=UTC) - timedelta(days=318)).isoformat(), - "ip_address": "10.1.2.5", - "hobbies": ["running", "dancing"], - "admin": False, - }, + local_db = { + 123: User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ), + 124: User( + id=124, + name="Jane Doe", + email="jane@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.5", + hobbies=["running", "dancing"], + admin=False, + ), } - examples.src.flask.FAKE_DB = MagicMock() - examples.src.flask.FAKE_DB.__delitem__.side_effect = ( - lambda key: db_values.__delitem__(key) - ) - examples.src.flask.FAKE_DB.__getitem__.side_effect = lambda key: db_values[key] - examples.src.flask.FAKE_DB.__contains__.side_effect = lambda key: key in db_values + def local_delitem(key: int) -> None: + del local_db[key] + + def local_contains(key: int) -> bool: + return key in local_db + + mock_db = MagicMock() + mock_db.__delitem__.side_effect = local_delitem + mock_db.__contains__.side_effect = local_contains + mock_db.is_mocked = True + examples.src.flask.FAKE_DB = mock_db def test_against_broker(broker: URL, verifier: Verifier) -> None: diff --git a/examples/tests/v3/conftest.py b/examples/tests/v3/conftest.py new file mode 100644 index 0000000000..485ff82bbc --- /dev/null +++ b/examples/tests/v3/conftest.py @@ -0,0 +1,15 @@ +""" +Common Pytest configuration for the V3 examples. +""" + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _setup_pact_logging() -> None: + """ + Set up logging for the pact package. + """ + from pact.v3 import ffi + + ffi.log_to_stderr("INFO") diff --git a/examples/tests/v3/test_00_consumer.py b/examples/tests/v3/test_00_consumer.py index c69a53c3a5..2807d1fc42 100644 --- a/examples/tests/v3/test_00_consumer.py +++ b/examples/tests/v3/test_00_consumer.py @@ -25,6 +25,7 @@ import pytest import requests +from examples.src.consumer import UserConsumer from pact.v3 import Pact @@ -71,7 +72,6 @@ def test_get_existing_user(pact: Pact) -> None: 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", @@ -96,11 +96,10 @@ def test_get_existing_user(pact: Pact) -> None: ) 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"]) + client = UserConsumer(str(srv.url)) + user = client.get_user(123) + assert user.id == 123 + assert user.name == "Verna Hampton" def test_get_non_existent_user(pact: Pact) -> None: @@ -133,7 +132,7 @@ def test_get_non_existent_user(pact: Pact) -> None: assert response.status_code == expected_response_code -def test_post_request_to_create_user(pact: Pact) -> None: +def test_create_user(pact: Pact) -> None: """ Test the POST request for creating a new user. @@ -142,36 +141,38 @@ def test_post_request_to_create_user(pact: Pact) -> None: including the request body and headers, and verifies that the response status code is 200 and the response body matches the expected user data. """ - expected: Dict[str, Any] = { + body = {"name": "Verna Hampton"} + expected_response: Dict[str, Any] = { "id": 124, - "name": "Jane Doe", - "email": "jane@example.com", + "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(), + }, } - header = {"Content-Type": "application/json"} - body = {"name": "Jane Doe", "email": "jane@example.com"} - expected_response_code: int = 200 ( pact.upon_receiving("a request to create a new user") .given("the specified user doesn't exist") .with_request(method="POST", path="/users/") - .with_body(json.dumps(body)) - .with_header("Content-Type", "application/json") + .with_body(json.dumps(body), content_type="application/json") .will_respond_with(status=200) - .with_body(content_type="application/json", body=json.dumps(expected)) + .with_body(content_type="application/json", body=json.dumps(expected_response)) ) with pact.serve() as srv: - response = requests.post( - f"{srv.url}/users/", headers=header, json=body, timeout=5 - ) - - assert response.status_code == expected_response_code - assert response.json() == { - "id": 124, - "name": "Jane Doe", - "email": "jane@example.com", - } + client = UserConsumer(str(srv.url)) + user = client.create_user(name="Verna Hampton") + assert user.id > 0 + assert user.name == "Verna Hampton" + assert user.created_on def test_delete_request_to_delete_user(pact: Pact) -> None: @@ -183,7 +184,6 @@ def test_delete_request_to_delete_user(pact: Pact) -> None: including the request body and headers, and verifies that the response status code is 200 and the response body matches the expected user data. """ - expected_response_code: int = 204 ( pact.upon_receiving("a request for deleting user") .given("user is present in DB") @@ -192,6 +192,5 @@ def test_delete_request_to_delete_user(pact: Pact) -> None: ) with pact.serve() as srv: - response = requests.delete(f"{srv.url}/users/124", timeout=5) - - assert response.status_code == expected_response_code + client = UserConsumer(str(srv.url)) + client.delete_user(124) diff --git a/examples/tests/v3/test_01_fastapi_provider.py b/examples/tests/v3/test_01_fastapi_provider.py index 14392ad9c8..c998488f60 100644 --- a/examples/tests/v3/test_01_fastapi_provider.py +++ b/examples/tests/v3/test_01_fastapi_provider.py @@ -27,7 +27,7 @@ from __future__ import annotations import time -from datetime import datetime, timezone +from datetime import UTC, datetime from multiprocessing import Process from typing import TYPE_CHECKING, Callable, Dict, Literal from unittest.mock import MagicMock @@ -35,7 +35,7 @@ import uvicorn from yarl import URL -from examples.src.fastapi import app +from examples.src.fastapi import User, app from pact.v3 import Verifier PROVIDER_URL = URL("http://localhost:8000") @@ -196,64 +196,91 @@ def mock_user_exists() -> None: 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, - } + mock_db.get.return_value = User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ) examples.src.fastapi.FAKE_DB = mock_db def mock_post_request_to_create_user() -> None: """ Mock the database for the post request to create a user. + + While the `FAKE_DB` is a dictionary in this example, one should imagine that + this is a real database. In this instance, we are replacing the calls to the + database with a local dictionary to avoid side effects; thereby eliminating + the need to stand up a real database for the tests. + + 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. """ import examples.src.fastapi + local_db: Dict[int, User] = {} + + def local_setitem(key: int, value: User) -> None: + local_db[key] = value + + def local_getitem(key: int) -> User: + return local_db[key] + mock_db = MagicMock() mock_db.__len__.return_value = 124 - mock_db.__setitem__.return_value = None - mock_db.__getitem__.return_value = { - "id": 124, - "created_on": "2024-09-06T05:07:06.745719+00:00", - "email": "jane@example.com", - "name": "Jane Doe", - } + mock_db.__setitem__.side_effect = local_setitem + mock_db.__getitem__.side_effect = local_getitem examples.src.fastapi.FAKE_DB = mock_db def mock_delete_request_to_delete_user() -> None: """ Mock the database for the delete request to delete a user. + + As with the `mock_post_request_to_create_user` function, we are using a + local dictionary to avoid side effects. This function replaces the calls to + the database with a local dictionary to avoid side effects. """ import examples.src.fastapi - db_values = { - 123: { - "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, - }, - 124: { - "id": 124, - "name": "Jane Doe", - "created_on": "2024-08-29T04:53:07.337793+00:00", - "ip_address": "10.1.2.5", - "hobbies": ["running", "dancing"], - "admin": False, - }, + local_db = { + 123: User( + id=123, + name="Verna Hampton", + email="verna@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.3", + hobbies=["hiking", "swimming"], + admin=False, + ), + 124: User( + id=124, + name="Jane Doe", + email="jane@example.com", + created_on=datetime.now(tz=UTC), + ip_address="10.1.2.5", + hobbies=["running", "dancing"], + admin=False, + ), } + def local_delitem(key: int) -> None: + del local_db[key] + + def local_contains(key: int) -> bool: + return key in local_db + mock_db = MagicMock() - mock_db.__delitem__.side_effect = lambda key: db_values.__delitem__(key) - mock_db.__getitem__.side_effect = lambda key: db_values[key] - mock_db.__contains__.side_effect = lambda key: key in db_values + mock_db.__delitem__.side_effect = local_delitem + mock_db.__contains__.side_effect = local_contains examples.src.fastapi.FAKE_DB = mock_db @@ -261,23 +288,21 @@ 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. + 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. """ import examples.src.fastapi if TYPE_CHECKING: + # During setup, the `FAKE_DB` is replaced with a MagicMock object. + # We need to inform the type checker that this has happened. examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.get.assert_called_once() + assert len(examples.src.fastapi.FAKE_DB.mock_calls) == 1 + 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 == {} @@ -289,23 +314,19 @@ 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. + 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. """ import examples.src.fastapi if TYPE_CHECKING: examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.get.assert_called_once() + assert len(examples.src.fastapi.FAKE_DB.mock_calls) == 1 + 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 == {} @@ -319,25 +340,18 @@ def verify_mock_post_request_to_create_user() -> None: if TYPE_CHECKING: examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.__getitem__.assert_called() + assert len(examples.src.fastapi.FAKE_DB.mock_calls) == 2 - expected_return = { - "id": 124, - "created_on": "2024-09-06T05:07:06.745719+00:00", - "email": "jane@example.com", - "name": "Jane Doe", - } - - examples.src.fastapi.FAKE_DB.__len__.assert_called() - assert ( - examples.src.fastapi.FAKE_DB.__getitem__.return_value == expected_return - ), "Unexpected return value from __getitem__()" + examples.src.fastapi.FAKE_DB.__getitem__.assert_called_once() + args, kwargs = examples.src.fastapi.FAKE_DB.__getitem__.call_args + assert len(args) == 1 + assert isinstance(args[0], int) + assert kwargs == {} - args, _ = examples.src.fastapi.FAKE_DB.__getitem__.call_args - assert isinstance( - args[0], int - ), f"Expected get() to be called with an integer, but got {type(args[0])}" - assert args[0] == 124, f"Expected get(124), but got get({args[0]})" + examples.src.fastapi.FAKE_DB.__len__.assert_called_once() + args, kwargs = examples.src.fastapi.FAKE_DB.__len__.call_args + assert len(args) == 0 + assert kwargs == {} examples.src.fastapi.FAKE_DB.reset_mock() @@ -348,11 +362,16 @@ def verify_mock_delete_request_to_delete_user() -> None: if TYPE_CHECKING: examples.src.fastapi.FAKE_DB = MagicMock() - examples.src.fastapi.FAKE_DB.__delitem__.assert_called() + assert len(examples.src.fastapi.FAKE_DB.mock_calls) == 2 - args, _ = examples.src.fastapi.FAKE_DB.__delitem__.call_args - assert isinstance( - args[0], int - ), f"Expected __delitem__() to be called with an integer, but got {type(args[0])}" + examples.src.fastapi.FAKE_DB.__delitem__.assert_called_once() + args, kwargs = examples.src.fastapi.FAKE_DB.__delitem__.call_args + assert len(args) == 1 + assert isinstance(args[0], int) + assert kwargs == {} - examples.src.fastapi.FAKE_DB.reset_mock() + examples.src.fastapi.FAKE_DB.__contains__.assert_called_once() + args, kwargs = examples.src.fastapi.FAKE_DB.__contains__.call_args + assert len(args) == 1 + assert isinstance(args[0], int) + assert kwargs == {}