Skip to content

Commit 0bbae63

Browse files
committed
chore: refactor tests
Signed-off-by: JP-Ellis <josh@jpellis.me>
1 parent 816ae11 commit 0bbae63

10 files changed

+469
-314
lines changed

examples/.ruff.toml

+4-4
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ extend = "../pyproject.toml"
22

33
[lint]
44
ignore = [
5-
"S101", # Forbid assert statements
6-
"D103", # Require docstring in public function
7-
"D104", # Require docstring in public package
8-
"PLR2004" # Forbid Magic Numbers
5+
"S101", # Forbid assert statements
6+
"D103", # Require docstring in public function
7+
"D104", # Require docstring in public package
8+
"PLR2004", # Forbid Magic Numbers
99
]
1010

1111
[lint.per-file-ignores]

examples/src/consumer.py

+30-20
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
[`User`][examples.src.consumer.User] class and the consumer fetches a user's
1313
information from a HTTP endpoint.
1414
15+
This also showcases how Pact tests differ from merely testing adherence to an
16+
OpenAPI specification. The Pact tests are more concerned with the practical use
17+
of the API, rather than the formally defined specification. So you will see
18+
below that as far as this consumer is concerned, the only information needed
19+
from the provider is the user's ID, name, and creation date. This is despite the
20+
provider having additional fields in the response.
21+
1522
Note that the code in this module is agnostic of Pact. The `pact-python`
1623
dependency only appears in the tests. This is because the consumer is not
1724
concerned with Pact, only the tests are.
@@ -21,7 +28,7 @@
2128

2229
from dataclasses import dataclass
2330
from datetime import datetime
24-
from typing import Any, Dict, Tuple
31+
from typing import Any, Dict
2532

2633
import requests
2734

@@ -104,42 +111,45 @@ def get_user(self, user_id: int) -> User:
104111
)
105112

106113
def create_user(
107-
self, user: Dict[str, Any], header: Dict[str, str]
108-
) -> Tuple[int, User]:
114+
self,
115+
*,
116+
name: str,
117+
) -> User:
109118
"""
110119
Create a new user on the server.
111120
112121
Args:
113-
user: The user data to create.
114-
header: The headers to send with the request.
122+
name: The name of the user to create.
115123
116124
Returns:
117-
The user data including the ID assigned by the server; Error if user exists.
125+
The user, if successfully created.
126+
127+
Raises:
128+
requests.HTTPError: If the server returns a non-200 response.
118129
"""
119130
uri = f"{self.base_uri}/users/"
120-
response = requests.post(uri, headers=header, json=user, timeout=5)
131+
response = requests.post(uri, json={"name": name}, timeout=5)
121132
response.raise_for_status()
122133
data: Dict[str, Any] = response.json()
123-
return (
124-
response.status_code,
125-
User(
126-
id=data["id"],
127-
name=data["name"],
128-
created_on=datetime.fromisoformat(data["created_on"]),
129-
),
134+
return User(
135+
id=data["id"],
136+
name=data["name"],
137+
created_on=datetime.fromisoformat(data["created_on"]),
130138
)
131139

132-
def delete_user(self, user_id: int) -> int:
140+
def delete_user(self, uid: int | User) -> None:
133141
"""
134142
Delete a user by ID from the server.
135143
136144
Args:
137-
user_id: The ID of the user to delete.
145+
uid: The user ID or user object to delete.
138146
139-
Returns:
140-
The response status code.
147+
Raises:
148+
requests.HTTPError: If the server returns a non-200 response.
141149
"""
142-
uri = f"{self.base_uri}/users/{user_id}"
150+
if isinstance(uid, User):
151+
uid = uid.id
152+
153+
uri = f"{self.base_uri}/users/{uid}"
143154
response = requests.delete(uri, timeout=5)
144155
response.raise_for_status()
145-
return response.status_code

examples/src/fastapi.py

+67-29
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
(the consumer) and returns a response. In this example, we have a simple
1313
endpoint which returns a user's information from a (fake) database.
1414
15+
This also showcases how Pact tests differ from merely testing adherence to an
16+
OpenAPI specification. The Pact tests are more concerned with the practical use
17+
of the API, rather than the formally defined specification. The User class
18+
defined here has additional fields which are not used by the consumer. Should
19+
the provider later decide to add or remove fields, Pact's consumer-driven
20+
testing will provide feedback on whether the consumer is compatible with the
21+
provider's changes.
22+
1523
Note that the code in this module is agnostic of Pact. The `pact-python`
1624
dependency only appears in the tests. This is because the consumer is not
1725
concerned with Pact, only the tests are.
@@ -20,28 +28,51 @@
2028
from __future__ import annotations
2129

2230
import logging
31+
from dataclasses import dataclass
32+
from datetime import UTC, datetime
2333
from typing import Any, Dict
2434

25-
from pydantic import BaseModel
26-
2735
from fastapi import FastAPI, HTTPException
28-
from fastapi.responses import JSONResponse
2936

3037
app = FastAPI()
3138
logger = logging.getLogger(__name__)
3239

3340

34-
class User(BaseModel):
35-
"""
36-
User data class.
37-
38-
This class is used to represent a user in the application. It is used to
39-
validate the incoming data and to dump the data to a dictionary.
40-
"""
41+
@dataclass()
42+
class User:
43+
"""User data class."""
4144

42-
id: int | None = None
45+
id: int
4346
name: str
44-
email: str
47+
created_on: datetime
48+
email: str | None
49+
ip_address: str | None
50+
hobbies: list[str]
51+
admin: bool
52+
53+
def __post_init__(self) -> None:
54+
"""
55+
Validate the User data.
56+
57+
This performs the following checks:
58+
59+
- The name cannot be empty
60+
- The id must be a positive integer
61+
62+
Raises:
63+
ValueError: If any of the above checks fail.
64+
"""
65+
if not self.name:
66+
msg = "User must have a name"
67+
raise ValueError(msg)
68+
69+
if self.id < 0:
70+
msg = "User ID must be a positive integer"
71+
raise ValueError(msg)
72+
73+
def __repr__(self) -> str:
74+
"""Return the user's name."""
75+
return f"User({self.id}:{self.name})"
4576

4677

4778
"""
@@ -52,11 +83,11 @@ class User(BaseModel):
5283
be mocked out to avoid the need for a real database. An example of this can be
5384
found in the [test suite][examples.tests.test_01_provider_fastapi].
5485
"""
55-
FAKE_DB: Dict[int, Dict[str, Any]] = {}
86+
FAKE_DB: Dict[int, User] = {}
5687

5788

5889
@app.get("/users/{uid}")
59-
async def get_user_by_id(uid: int) -> JSONResponse:
90+
async def get_user_by_id(uid: int) -> User:
6091
"""
6192
Fetch a user by their ID.
6293
@@ -68,12 +99,12 @@ async def get_user_by_id(uid: int) -> JSONResponse:
6899
"""
69100
user = FAKE_DB.get(uid)
70101
if not user:
71-
return JSONResponse(status_code=404, content={"error": "User not found"})
72-
return JSONResponse(status_code=200, content=user)
102+
raise HTTPException(status_code=404, detail="User not found")
103+
return user
73104

74105

75106
@app.post("/users/")
76-
async def create_new_user(user: User) -> JSONResponse:
107+
async def create_new_user(user: dict[str, Any]) -> User:
77108
"""
78109
Create a new user .
79110
@@ -83,26 +114,33 @@ async def create_new_user(user: User) -> JSONResponse:
83114
Returns:
84115
The status code 200 and user data if successfully created, HTTP 404 if not
85116
"""
86-
if user.id is not None:
117+
if "id" in user:
87118
raise HTTPException(status_code=400, detail="ID should not be provided.")
88-
new_uid = len(FAKE_DB)
89-
FAKE_DB[new_uid] = user.model_dump()
90-
91-
return JSONResponse(status_code=200, content=FAKE_DB[new_uid])
92-
93-
94-
@app.delete("/users/{user_id}", status_code=204)
95-
async def delete_user(user_id: int): # noqa: ANN201
119+
uid = len(FAKE_DB)
120+
FAKE_DB[uid] = User(
121+
id=uid,
122+
name=user["name"],
123+
created_on=datetime.now(tz=UTC),
124+
email=user.get("email"),
125+
ip_address=user.get("ip_address"),
126+
hobbies=user.get("hobbies", []),
127+
admin=user.get("admin", False),
128+
)
129+
return FAKE_DB[uid]
130+
131+
132+
@app.delete("/users/{uid}", status_code=204)
133+
async def delete_user(uid: int): # noqa: ANN201
96134
"""
97135
Delete an existing user .
98136
99137
Args:
100-
user_id: The ID of the user to delete
138+
uid: The ID of the user to delete
101139
102140
Returns:
103141
The status code 204, HTTP 404 if not
104142
"""
105-
if user_id not in FAKE_DB:
143+
if uid not in FAKE_DB:
106144
raise HTTPException(status_code=404, detail="User not found")
107145

108-
del FAKE_DB[user_id]
146+
del FAKE_DB[uid]

examples/src/flask.py

+83-24
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,67 @@
2020
from __future__ import annotations
2121

2222
import logging
23-
from typing import Any, Dict, Tuple, Union
23+
from dataclasses import dataclass
24+
from datetime import UTC, datetime
25+
from typing import Any, Dict, Tuple
2426

2527
from flask import Flask, Response, abort, jsonify, request
2628

2729
logger = logging.getLogger(__name__)
28-
2930
app = Flask(__name__)
31+
32+
33+
@dataclass()
34+
class User:
35+
"""User data class."""
36+
37+
id: int
38+
name: str
39+
created_on: datetime
40+
email: str | None
41+
ip_address: str | None
42+
hobbies: list[str]
43+
admin: bool
44+
45+
def __post_init__(self) -> None:
46+
"""
47+
Validate the User data.
48+
49+
This performs the following checks:
50+
51+
- The name cannot be empty
52+
- The id must be a positive integer
53+
54+
Raises:
55+
ValueError: If any of the above checks fail.
56+
"""
57+
if not self.name:
58+
msg = "User must have a name"
59+
raise ValueError(msg)
60+
61+
if self.id < 0:
62+
msg = "User ID must be a positive integer"
63+
raise ValueError(msg)
64+
65+
def __repr__(self) -> str:
66+
"""Return the user's name."""
67+
return f"User({self.id}:{self.name})"
68+
69+
def dict(self) -> dict[str, Any]:
70+
"""
71+
Return the user's data as a dict.
72+
"""
73+
return {
74+
"id": self.id,
75+
"name": self.name,
76+
"created_on": self.created_on.isoformat(),
77+
"email": self.email,
78+
"ip_address": self.ip_address,
79+
"hobbies": self.hobbies,
80+
"admin": self.admin,
81+
}
82+
83+
3084
"""
3185
As this is a simple example, we'll use a simple dict to represent a database.
3286
This would be replaced with a real database in a real application.
@@ -35,11 +89,11 @@
3589
be mocked out to avoid the need for a real database. An example of this can be
3690
found in the [test suite][examples.tests.test_01_provider_flask].
3791
"""
38-
FAKE_DB: Dict[int, Dict[str, Any]] = {}
92+
FAKE_DB: Dict[int, User] = {}
3993

4094

41-
@app.route("/users/<uid>")
42-
def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int]]:
95+
@app.route("/users/<int:uid>")
96+
def get_user_by_id(uid: int) -> Response | Tuple[Response, int]:
4397
"""
4498
Fetch a user by their ID.
4599
@@ -49,29 +103,34 @@ def get_user_by_id(uid: int) -> Union[Dict[str, Any], tuple[Dict[str, Any], int]
49103
Returns:
50104
The user data if found, HTTP 404 if not
51105
"""
52-
user = FAKE_DB.get(int(uid))
106+
user = FAKE_DB.get(uid)
53107
if not user:
54-
return {"error": "User not found"}, 404
55-
return user
108+
return jsonify({"detail": "User not found"}), 404
109+
return jsonify(user.dict())
56110

57111

58112
@app.route("/users/", methods=["POST"])
59-
def create_user() -> Tuple[Response, int]:
113+
def create_user() -> Response:
60114
if request.json is None:
61115
abort(400, description="Invalid JSON data")
62116

63-
data: Dict[str, Any] = request.json
64-
new_uid: int = len(FAKE_DB)
65-
if new_uid in FAKE_DB:
66-
abort(400, description="User already exists")
67-
68-
FAKE_DB[new_uid] = {"id": new_uid, "name": data["name"], "email": data["email"]}
69-
return jsonify(FAKE_DB[new_uid]), 200
70-
71-
72-
@app.route("/users/<user_id>", methods=["DELETE"])
73-
def delete_user(user_id: int) -> Tuple[str, int]:
74-
if user_id not in FAKE_DB:
75-
abort(404, description="User not found")
76-
del FAKE_DB[user_id]
77-
return "", 204 # No Content status code
117+
user: Dict[str, Any] = request.json
118+
uid = len(FAKE_DB)
119+
FAKE_DB[uid] = User(
120+
id=uid,
121+
name=user["name"],
122+
created_on=datetime.now(tz=UTC),
123+
email=user.get("email"),
124+
ip_address=user.get("ip_address"),
125+
hobbies=user.get("hobbies", []),
126+
admin=user.get("admin", False),
127+
)
128+
return jsonify(FAKE_DB[uid].dict())
129+
130+
131+
@app.route("/users/<int:uid>", methods=["DELETE"])
132+
def delete_user(uid: int) -> Tuple[str | Response, int]:
133+
if uid not in FAKE_DB:
134+
return jsonify({"detail": "User not found"}), 404
135+
del FAKE_DB[uid]
136+
return "", 204

0 commit comments

Comments
 (0)