From fc65cddcc74283a11bcdc2822874bea39fe61e0d Mon Sep 17 00:00:00 2001 From: JP-Ellis Date: Thu, 19 Sep 2024 13:31:32 +1000 Subject: [PATCH] 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 | 104 ++++++----- 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, 466 insertions(+), 314 deletions(-) create mode 100644 examples/tests/v3/conftest.py diff --git a/examples/.ruff.toml b/examples/.ruff.toml index 5222985a3..6b2ed4c15 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 b2815d297..453297923 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 e522b08f8..013e95f13 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 7fa32cdea..98107e3c1 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 4c5a86dfd..273b4405d 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 c9fd23ceb..0825b1c4c 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 2ebd0c80a..3faebf85a 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,14 @@ 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. """ + 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 +74,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 +119,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 +136,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 +157,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 000000000..485ff82bb --- /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 c69a53c3a..2807d1fc4 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 14392ad9c..c998488f6 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 == {}