spud-testing 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spud_testing-0.1.0/LICENSE +0 -0
- spud_testing-0.1.0/PKG-INFO +51 -0
- spud_testing-0.1.0/README.md +37 -0
- spud_testing-0.1.0/pyproject.toml +18 -0
- spud_testing-0.1.0/setup.cfg +4 -0
- spud_testing-0.1.0/src/spud/__init__.py +7 -0
- spud_testing-0.1.0/src/spud/api_client.py +82 -0
- spud_testing-0.1.0/src/spud/api_response.py +31 -0
- spud_testing-0.1.0/src/spud/endpoint.py +15 -0
- spud_testing-0.1.0/src/spud/exceptions.py +2 -0
- spud_testing-0.1.0/src/spud/httpx_transport.py +21 -0
- spud_testing-0.1.0/src/spud/raw_response_protocol.py +18 -0
- spud_testing-0.1.0/src/spud/response_checks.py +134 -0
- spud_testing-0.1.0/src/spud/transport_protocol.py +17 -0
- spud_testing-0.1.0/src/spud_testing.egg-info/PKG-INFO +51 -0
- spud_testing-0.1.0/src/spud_testing.egg-info/SOURCES.txt +22 -0
- spud_testing-0.1.0/src/spud_testing.egg-info/dependency_links.txt +1 -0
- spud_testing-0.1.0/src/spud_testing.egg-info/requires.txt +6 -0
- spud_testing-0.1.0/src/spud_testing.egg-info/top_level.txt +1 -0
- spud_testing-0.1.0/tests/test_api_client.py +189 -0
- spud_testing-0.1.0/tests/test_api_response.py +12 -0
- spud_testing-0.1.0/tests/test_endpoint.py +69 -0
- spud_testing-0.1.0/tests/test_httpx_transport.py +36 -0
- spud_testing-0.1.0/tests/test_response_checks.py +330 -0
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spud-testing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Python toolkit for API testing
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: httpx
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Requires-Dist: mypy; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# SPUD
|
|
16
|
+
Stop planting the same test framework over and over again.
|
|
17
|
+
|
|
18
|
+
Spud is a Python testing framework for QA engineers who don't want to rebuild the same setup every time they join a new project.
|
|
19
|
+
|
|
20
|
+
It comes with the stuff you usually end up writing anyway, but doesn't lock you into one way of doing things. Use what you need, replace what you don't, or build on top of it.
|
|
21
|
+
|
|
22
|
+
Plant once. Grow tests.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
```bash
|
|
26
|
+
pip install spud-testing
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
```python
|
|
31
|
+
from spud import ApiClient
|
|
32
|
+
|
|
33
|
+
client = ApiClient(base_url="https://jsonplaceholder.typicode.com")
|
|
34
|
+
|
|
35
|
+
response = client.get("/users/1")
|
|
36
|
+
|
|
37
|
+
response.check.status(200).json_path("id", 1)
|
|
38
|
+
|
|
39
|
+
print(response.json())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- HTTP methods: GET, POST, PUT, PATCH, DELETE
|
|
44
|
+
- Configurable base URL and request headers
|
|
45
|
+
- Query parameters and JSON request bodies
|
|
46
|
+
- Pluggable HTTP transport (httpx by default)
|
|
47
|
+
- HTTP status assertions
|
|
48
|
+
- JSON path assertions with nested objects and arrays
|
|
49
|
+
- Strict JSON value comparison
|
|
50
|
+
- Chainable response assertions
|
|
51
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPUD
|
|
2
|
+
Stop planting the same test framework over and over again.
|
|
3
|
+
|
|
4
|
+
Spud is a Python testing framework for QA engineers who don't want to rebuild the same setup every time they join a new project.
|
|
5
|
+
|
|
6
|
+
It comes with the stuff you usually end up writing anyway, but doesn't lock you into one way of doing things. Use what you need, replace what you don't, or build on top of it.
|
|
7
|
+
|
|
8
|
+
Plant once. Grow tests.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
```bash
|
|
12
|
+
pip install spud-testing
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
```python
|
|
17
|
+
from spud import ApiClient
|
|
18
|
+
|
|
19
|
+
client = ApiClient(base_url="https://jsonplaceholder.typicode.com")
|
|
20
|
+
|
|
21
|
+
response = client.get("/users/1")
|
|
22
|
+
|
|
23
|
+
response.check.status(200).json_path("id", 1)
|
|
24
|
+
|
|
25
|
+
print(response.json())
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
- HTTP methods: GET, POST, PUT, PATCH, DELETE
|
|
30
|
+
- Configurable base URL and request headers
|
|
31
|
+
- Query parameters and JSON request bodies
|
|
32
|
+
- Pluggable HTTP transport (httpx by default)
|
|
33
|
+
- HTTP status assertions
|
|
34
|
+
- JSON path assertions with nested objects and arrays
|
|
35
|
+
- Strict JSON value comparison
|
|
36
|
+
- Chainable response assertions
|
|
37
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "spud-testing"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.10"
|
|
5
|
+
dependencies = ["httpx"]
|
|
6
|
+
description = "A lightweight Python toolkit for API testing"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["setuptools"]
|
|
11
|
+
build-backend = "setuptools.build_meta"
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
dev = ["pytest", "mypy", "ruff"]
|
|
15
|
+
|
|
16
|
+
[tool.mypy]
|
|
17
|
+
mypy_path = "src"
|
|
18
|
+
explicit_package_bases = true
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .api_client import ApiClient
|
|
2
|
+
from .api_response import ApiResponse
|
|
3
|
+
from .endpoint import Endpoint
|
|
4
|
+
from .exceptions import SpudCheckError
|
|
5
|
+
from .response_checks import ResponseChecks
|
|
6
|
+
|
|
7
|
+
__all__ = ["ApiClient", "ApiResponse", "Endpoint", "ResponseChecks", "SpudCheckError"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from . import api_response, httpx_transport, transport_protocol
|
|
5
|
+
from .transport_protocol import HttpMethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiClient:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
base_url: str,
|
|
12
|
+
transport: transport_protocol.TransportProtocol | None = None,
|
|
13
|
+
headers: Mapping[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.base_url = base_url.rstrip("/")
|
|
16
|
+
if transport is None:
|
|
17
|
+
self.transport: transport_protocol.TransportProtocol = (
|
|
18
|
+
httpx_transport.HttpxTransport()
|
|
19
|
+
)
|
|
20
|
+
else:
|
|
21
|
+
self.transport = transport
|
|
22
|
+
self.headers = headers
|
|
23
|
+
|
|
24
|
+
def get(
|
|
25
|
+
self,
|
|
26
|
+
path: str,
|
|
27
|
+
params: Mapping[str, Any] | None = None,
|
|
28
|
+
headers: Mapping[str, str] | None = None,
|
|
29
|
+
) -> api_response.ApiResponse:
|
|
30
|
+
return self.request(method="GET", path=path, params=params, headers=headers)
|
|
31
|
+
|
|
32
|
+
def post(
|
|
33
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
34
|
+
) -> api_response.ApiResponse:
|
|
35
|
+
return self.request(method="POST", path=path, json=json, headers=headers)
|
|
36
|
+
|
|
37
|
+
def put(
|
|
38
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
39
|
+
) -> api_response.ApiResponse:
|
|
40
|
+
return self.request(method="PUT", path=path, json=json, headers=headers)
|
|
41
|
+
|
|
42
|
+
def patch(
|
|
43
|
+
self, path: str, json: Any = None, headers: Mapping[str, str] | None = None
|
|
44
|
+
) -> api_response.ApiResponse:
|
|
45
|
+
return self.request(method="PATCH", path=path, json=json, headers=headers)
|
|
46
|
+
|
|
47
|
+
def delete(
|
|
48
|
+
self,
|
|
49
|
+
path: str,
|
|
50
|
+
headers: Mapping[str, str] | None = None,
|
|
51
|
+
params: Mapping[str, Any] | None = None,
|
|
52
|
+
) -> api_response.ApiResponse:
|
|
53
|
+
return self.request(method="DELETE", path=path, headers=headers, params=params)
|
|
54
|
+
|
|
55
|
+
def request(
|
|
56
|
+
self,
|
|
57
|
+
method: HttpMethod,
|
|
58
|
+
path: str,
|
|
59
|
+
params: Mapping[str, Any] | None = None,
|
|
60
|
+
headers: Mapping[str, str] | None = None,
|
|
61
|
+
json: Any = None,
|
|
62
|
+
) -> api_response.ApiResponse:
|
|
63
|
+
if not path.startswith("/"):
|
|
64
|
+
raise ValueError("Path should start with '/'")
|
|
65
|
+
if headers is None:
|
|
66
|
+
effective_headers = self.headers
|
|
67
|
+
else:
|
|
68
|
+
if self.headers is None:
|
|
69
|
+
effective_headers = headers
|
|
70
|
+
else:
|
|
71
|
+
update_headers = dict(self.headers)
|
|
72
|
+
update_headers.update(headers)
|
|
73
|
+
effective_headers = update_headers
|
|
74
|
+
|
|
75
|
+
raw_response = self.transport.request(
|
|
76
|
+
method=method,
|
|
77
|
+
url=self.base_url + path,
|
|
78
|
+
params=params,
|
|
79
|
+
headers=effective_headers,
|
|
80
|
+
json=json,
|
|
81
|
+
)
|
|
82
|
+
return api_response.ApiResponse(raw_response)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from spud import raw_response_protocol, response_checks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiResponse:
|
|
7
|
+
def __init__(self, raw_response: raw_response_protocol.RawResponseProtocol) -> None:
|
|
8
|
+
self.raw = raw_response
|
|
9
|
+
self._checker = None
|
|
10
|
+
|
|
11
|
+
def json(self) -> Any:
|
|
12
|
+
return self.raw.json()
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def status_code(self):
|
|
16
|
+
return self.raw.status_code
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def headers(self):
|
|
20
|
+
return self.raw.headers
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def text(self):
|
|
24
|
+
return self.raw.text
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def check(self):
|
|
28
|
+
if self._checker is None:
|
|
29
|
+
checker = response_checks.ResponseChecks(response=self)
|
|
30
|
+
self._checker = checker
|
|
31
|
+
return self._checker
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from . import api_client
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Endpoint:
|
|
5
|
+
path: str
|
|
6
|
+
|
|
7
|
+
def __init__(self, client: api_client.ApiClient) -> None:
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def __init_subclass__(cls, **kwargs):
|
|
11
|
+
super().__init_subclass__(**kwargs)
|
|
12
|
+
if not hasattr(cls, "path"):
|
|
13
|
+
raise TypeError("Endpoint subclass must define 'path'")
|
|
14
|
+
if not isinstance(cls.path, str):
|
|
15
|
+
raise TypeError("Endpoint path must be a string")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from . import raw_response_protocol
|
|
7
|
+
from .transport_protocol import HttpMethod
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpxTransport:
|
|
11
|
+
def request(
|
|
12
|
+
self,
|
|
13
|
+
method: HttpMethod,
|
|
14
|
+
url: str,
|
|
15
|
+
params: Mapping[str, Any] | None = None,
|
|
16
|
+
headers: Mapping[str, str] | None = None,
|
|
17
|
+
json: Any = None,
|
|
18
|
+
) -> raw_response_protocol.RawResponseProtocol:
|
|
19
|
+
return httpx.request(
|
|
20
|
+
method=method, url=url, params=params, headers=headers, json=json
|
|
21
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RawResponseProtocol(Protocol):
|
|
6
|
+
@property
|
|
7
|
+
def headers(self):
|
|
8
|
+
return Mapping[str, str]
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def text(self):
|
|
12
|
+
return self.text
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def status_code(self) -> int:
|
|
16
|
+
return self.status_code
|
|
17
|
+
|
|
18
|
+
def json(self) -> Any: ...
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
from spud.exceptions import SpudCheckError
|
|
5
|
+
|
|
6
|
+
_MISSING = object()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseChecks:
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from . import api_response
|
|
12
|
+
|
|
13
|
+
def __init__(self, response: "api_response.ApiResponse") -> None:
|
|
14
|
+
self.response = response
|
|
15
|
+
|
|
16
|
+
def status(self, expected_status: int) -> "ResponseChecks":
|
|
17
|
+
status = self.response.status_code
|
|
18
|
+
phrase = f"Expected status {expected_status}, got {status}"
|
|
19
|
+
if status != expected_status:
|
|
20
|
+
try:
|
|
21
|
+
phrase = phrase + f" {HTTPStatus(status).phrase}"
|
|
22
|
+
except ValueError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
raise SpudCheckError(phrase)
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def json_path(self, path: str = "", expected: Any = _MISSING) -> "ResponseChecks":
|
|
29
|
+
if expected is _MISSING:
|
|
30
|
+
raise TypeError("json_path() missing required argument: expected")
|
|
31
|
+
current = self.response.json()
|
|
32
|
+
for key in parse_json_path(path=path):
|
|
33
|
+
if not (
|
|
34
|
+
(isinstance(current, dict) and isinstance(key, str))
|
|
35
|
+
or (isinstance(current, list) and isinstance(key, int))
|
|
36
|
+
):
|
|
37
|
+
raise SpudCheckError(f"JSON path {path!r} not found")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if isinstance(current, dict) and isinstance(key, str):
|
|
41
|
+
current = current[key]
|
|
42
|
+
elif isinstance(current, list) and isinstance(key, int):
|
|
43
|
+
current = current[key]
|
|
44
|
+
except (KeyError, IndexError):
|
|
45
|
+
raise SpudCheckError(f"JSON path {path!r} not found") from None
|
|
46
|
+
if not self.json_equal(current, expected):
|
|
47
|
+
raise SpudCheckError(f"expected {expected!r}, got {current!r}")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def json_equal(self, actual: Any, expected: Any) -> bool:
|
|
51
|
+
if type(actual) != type(expected):
|
|
52
|
+
return False
|
|
53
|
+
if isinstance(actual, dict):
|
|
54
|
+
if actual.keys() != expected.keys():
|
|
55
|
+
return False
|
|
56
|
+
for key in actual:
|
|
57
|
+
if not self.json_equal(actual[key], expected[key]):
|
|
58
|
+
return False
|
|
59
|
+
return True
|
|
60
|
+
if isinstance(actual, list):
|
|
61
|
+
if len(actual) != len(expected):
|
|
62
|
+
return False
|
|
63
|
+
for actual_item, expected_item in zip(actual, expected):
|
|
64
|
+
if not self.json_equal(actual_item, expected_item):
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
return actual == expected
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_json_path(path: str) -> list[str | int]:
|
|
71
|
+
tokens: list[str | int] = []
|
|
72
|
+
buffer = ""
|
|
73
|
+
inside_brackets = False
|
|
74
|
+
after_closing_bracket = False
|
|
75
|
+
if path.endswith("."):
|
|
76
|
+
raise ValueError(f"Invalid JSON path {path!r}: empty path segment")
|
|
77
|
+
for index, char in enumerate(path):
|
|
78
|
+
if char == "[":
|
|
79
|
+
if inside_brackets:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Invalid JSON path {path!r}: unexpected opening bracket"
|
|
82
|
+
)
|
|
83
|
+
if index > 0 and path[index - 1] == ".":
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Invalid JSON path {path!r}: unexpected opening bracket"
|
|
86
|
+
)
|
|
87
|
+
if buffer:
|
|
88
|
+
tokens.append(buffer)
|
|
89
|
+
buffer = ""
|
|
90
|
+
inside_brackets = True
|
|
91
|
+
after_closing_bracket = False
|
|
92
|
+
continue
|
|
93
|
+
if char == "]":
|
|
94
|
+
if not inside_brackets:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Invalid JSON path {path!r}: unexpected closing bracket"
|
|
97
|
+
)
|
|
98
|
+
if buffer:
|
|
99
|
+
try:
|
|
100
|
+
tokens.append(int(buffer))
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Invalid JSON path {path!r}: array index must be an integer"
|
|
104
|
+
) from None
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Invalid JSON path {path!r}: array index cannot be empty"
|
|
108
|
+
) from None
|
|
109
|
+
buffer = ""
|
|
110
|
+
inside_brackets = False
|
|
111
|
+
after_closing_bracket = True
|
|
112
|
+
continue
|
|
113
|
+
if char == ".":
|
|
114
|
+
if inside_brackets:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Invalid JSON path {path!r}: array index must be integer"
|
|
117
|
+
)
|
|
118
|
+
if buffer == "" and not after_closing_bracket:
|
|
119
|
+
raise ValueError(f"Invalid JSON path {path!r}: empty path segment")
|
|
120
|
+
if buffer:
|
|
121
|
+
tokens.append(buffer)
|
|
122
|
+
buffer = ""
|
|
123
|
+
after_closing_bracket = False
|
|
124
|
+
continue
|
|
125
|
+
if after_closing_bracket:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Invalid JSON path {path!r}: expected separator after closing bracket"
|
|
128
|
+
)
|
|
129
|
+
buffer += char
|
|
130
|
+
if buffer:
|
|
131
|
+
tokens.append(buffer)
|
|
132
|
+
if inside_brackets:
|
|
133
|
+
raise ValueError(f"Invalid JSON path {path!r}: unclosed bracket")
|
|
134
|
+
return tokens
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any, Literal, Protocol
|
|
3
|
+
|
|
4
|
+
from spud import raw_response_protocol
|
|
5
|
+
|
|
6
|
+
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TransportProtocol(Protocol):
|
|
10
|
+
def request(
|
|
11
|
+
self,
|
|
12
|
+
method: HttpMethod,
|
|
13
|
+
url: str,
|
|
14
|
+
params: Mapping[str, Any] | None = None,
|
|
15
|
+
headers: Mapping[str, str] | None = None,
|
|
16
|
+
json: Any = None,
|
|
17
|
+
) -> raw_response_protocol.RawResponseProtocol: ...
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spud-testing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Python toolkit for API testing
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: httpx
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Requires-Dist: mypy; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# SPUD
|
|
16
|
+
Stop planting the same test framework over and over again.
|
|
17
|
+
|
|
18
|
+
Spud is a Python testing framework for QA engineers who don't want to rebuild the same setup every time they join a new project.
|
|
19
|
+
|
|
20
|
+
It comes with the stuff you usually end up writing anyway, but doesn't lock you into one way of doing things. Use what you need, replace what you don't, or build on top of it.
|
|
21
|
+
|
|
22
|
+
Plant once. Grow tests.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
```bash
|
|
26
|
+
pip install spud-testing
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
```python
|
|
31
|
+
from spud import ApiClient
|
|
32
|
+
|
|
33
|
+
client = ApiClient(base_url="https://jsonplaceholder.typicode.com")
|
|
34
|
+
|
|
35
|
+
response = client.get("/users/1")
|
|
36
|
+
|
|
37
|
+
response.check.status(200).json_path("id", 1)
|
|
38
|
+
|
|
39
|
+
print(response.json())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
- HTTP methods: GET, POST, PUT, PATCH, DELETE
|
|
44
|
+
- Configurable base URL and request headers
|
|
45
|
+
- Query parameters and JSON request bodies
|
|
46
|
+
- Pluggable HTTP transport (httpx by default)
|
|
47
|
+
- HTTP status assertions
|
|
48
|
+
- JSON path assertions with nested objects and arrays
|
|
49
|
+
- Strict JSON value comparison
|
|
50
|
+
- Chainable response assertions
|
|
51
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/spud/__init__.py
|
|
5
|
+
src/spud/api_client.py
|
|
6
|
+
src/spud/api_response.py
|
|
7
|
+
src/spud/endpoint.py
|
|
8
|
+
src/spud/exceptions.py
|
|
9
|
+
src/spud/httpx_transport.py
|
|
10
|
+
src/spud/raw_response_protocol.py
|
|
11
|
+
src/spud/response_checks.py
|
|
12
|
+
src/spud/transport_protocol.py
|
|
13
|
+
src/spud_testing.egg-info/PKG-INFO
|
|
14
|
+
src/spud_testing.egg-info/SOURCES.txt
|
|
15
|
+
src/spud_testing.egg-info/dependency_links.txt
|
|
16
|
+
src/spud_testing.egg-info/requires.txt
|
|
17
|
+
src/spud_testing.egg-info/top_level.txt
|
|
18
|
+
tests/test_api_client.py
|
|
19
|
+
tests/test_api_response.py
|
|
20
|
+
tests/test_endpoint.py
|
|
21
|
+
tests/test_httpx_transport.py
|
|
22
|
+
tests/test_response_checks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spud
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from unittest.mock import Mock, create_autospec
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spud import api_client, httpx_transport, raw_response_protocol, transport_protocol
|
|
6
|
+
from spud.api_response import ApiResponse
|
|
7
|
+
from spud.raw_response_protocol import RawResponseProtocol
|
|
8
|
+
from spud.transport_protocol import TransportProtocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("base_url", ["http://google.com/", "http://google.com///"])
|
|
12
|
+
def test_base_url_removes_trailing_slash(base_url):
|
|
13
|
+
transport = create_autospec(spec=TransportProtocol, instance=True)
|
|
14
|
+
client = api_client.ApiClient(base_url, transport)
|
|
15
|
+
assert client.base_url == "http://google.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_get_raises_value_error_when_path_has_no_leading_slash():
|
|
19
|
+
transport = create_autospec(spec=TransportProtocol, instance=True)
|
|
20
|
+
client = api_client.ApiClient("http://google.com", transport)
|
|
21
|
+
with pytest.raises(ValueError):
|
|
22
|
+
client.get("search")
|
|
23
|
+
transport.request.assert_not_called()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_get_delegates_to_transport():
|
|
27
|
+
headers = {"Authorization": "Bearer Token..."}
|
|
28
|
+
params = {"q": 123}
|
|
29
|
+
fake_raw_response = object()
|
|
30
|
+
transport = create_autospec(spec=TransportProtocol, instance=True)
|
|
31
|
+
client = api_client.ApiClient("http://google.com", transport)
|
|
32
|
+
transport.request.return_value = fake_raw_response
|
|
33
|
+
response = client.get("/search", params=params, headers=headers)
|
|
34
|
+
assert response.raw is fake_raw_response
|
|
35
|
+
transport.request.assert_called_once_with(
|
|
36
|
+
method="GET",
|
|
37
|
+
url="http://google.com/search",
|
|
38
|
+
params=params,
|
|
39
|
+
headers=headers,
|
|
40
|
+
json=None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_post_delegates_to_transport():
|
|
45
|
+
json = {"name": "Ruslan", "age": 30}
|
|
46
|
+
headers = {"Authorization": "Bearer token"}
|
|
47
|
+
fake_raw_response = object()
|
|
48
|
+
transport = create_autospec(spec=TransportProtocol, instance=True)
|
|
49
|
+
client = api_client.ApiClient("http://google.com", transport)
|
|
50
|
+
transport.request.return_value = fake_raw_response
|
|
51
|
+
response = client.post("/create", json=json, headers=headers)
|
|
52
|
+
assert response.raw is fake_raw_response
|
|
53
|
+
transport.request.assert_called_once_with(
|
|
54
|
+
method="POST",
|
|
55
|
+
url="http://google.com/create",
|
|
56
|
+
json=json,
|
|
57
|
+
headers=headers,
|
|
58
|
+
params=None,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_status_code_returns_raw_response_status_code():
|
|
63
|
+
status_code = 201
|
|
64
|
+
raw_response_mock = Mock(spec=RawResponseProtocol)
|
|
65
|
+
raw_response_mock.status_code = status_code
|
|
66
|
+
response = ApiResponse(raw_response_mock)
|
|
67
|
+
assert status_code == response.status_code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_headers_returns_raw_response_headers():
|
|
71
|
+
headers = {"Authorization": "Bearer token"}
|
|
72
|
+
raw_response_mock = Mock(spec=RawResponseProtocol)
|
|
73
|
+
raw_response_mock.headers = headers
|
|
74
|
+
response = ApiResponse(raw_response_mock)
|
|
75
|
+
assert headers == response.headers
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_text_returns_raw_response_text():
|
|
79
|
+
text = "text"
|
|
80
|
+
raw_response_mock = Mock(spec=RawResponseProtocol)
|
|
81
|
+
raw_response_mock.text = text
|
|
82
|
+
response = ApiResponse(raw_response_mock)
|
|
83
|
+
assert text == response.text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_json_returns_raw_response_json():
|
|
87
|
+
json = {"name": "Ruslan", "age": 30}
|
|
88
|
+
raw_response_mock = Mock(spec=RawResponseProtocol)
|
|
89
|
+
raw_response_mock.json.return_value = json
|
|
90
|
+
response = ApiResponse(raw_response_mock)
|
|
91
|
+
assert json == response.json()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"method_name, expected_method, kwargs",
|
|
96
|
+
[
|
|
97
|
+
(
|
|
98
|
+
"post",
|
|
99
|
+
"POST",
|
|
100
|
+
{"json": {"status": "ok"}, "headers": {"Authorization": "Bearer token"}},
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"get",
|
|
104
|
+
"GET",
|
|
105
|
+
{"params": {"q": 123}, "headers": {"Authorization": "Bearer token"}},
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
"put",
|
|
109
|
+
"PUT",
|
|
110
|
+
{"json": {"status": "ok"}, "headers": {"Authorization": "Bearer token"}},
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
"patch",
|
|
114
|
+
"PATCH",
|
|
115
|
+
{"json": {"status": "ok"}, "headers": {"Authorization": "Bearer token"}},
|
|
116
|
+
),
|
|
117
|
+
(
|
|
118
|
+
"delete",
|
|
119
|
+
"DELETE",
|
|
120
|
+
{"headers": {"Authorization": "Bearer token"}, "params": {"q": 123}},
|
|
121
|
+
),
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
def test_method_deligates_to_transport(method_name, expected_method, kwargs):
|
|
125
|
+
transport = create_autospec(spec=TransportProtocol, instance=True)
|
|
126
|
+
raw_response_mock = Mock(spec=RawResponseProtocol)
|
|
127
|
+
transport.request.return_value = raw_response_mock
|
|
128
|
+
client = api_client.ApiClient("http://google.com", transport)
|
|
129
|
+
method = getattr(client, method_name)
|
|
130
|
+
response = method(path="/search", **kwargs)
|
|
131
|
+
transport.request.assert_called_once_with(
|
|
132
|
+
method=expected_method,
|
|
133
|
+
url="http://google.com/search",
|
|
134
|
+
json=kwargs.get("json"),
|
|
135
|
+
headers=kwargs.get("headers"),
|
|
136
|
+
params=kwargs.get("params"),
|
|
137
|
+
)
|
|
138
|
+
assert response.raw is raw_response_mock
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_request_with_default_transport():
|
|
142
|
+
client = api_client.ApiClient("http://google.com")
|
|
143
|
+
assert isinstance(client.transport, httpx_transport.HttpxTransport)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_api_client_uses_default_headers():
|
|
147
|
+
transport = create_autospec(
|
|
148
|
+
spec=transport_protocol.TransportProtocol, instance=True
|
|
149
|
+
)
|
|
150
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
151
|
+
transport.request.return_value = raw_response_mock
|
|
152
|
+
headers = {"Authorization": "Bearer abc"}
|
|
153
|
+
client = api_client.ApiClient(
|
|
154
|
+
"http://google.com", transport=transport, headers=headers
|
|
155
|
+
)
|
|
156
|
+
client.get("/orders")
|
|
157
|
+
transport.request.assert_called_once_with(
|
|
158
|
+
method="GET",
|
|
159
|
+
url="http://google.com/orders",
|
|
160
|
+
params=None,
|
|
161
|
+
headers=headers,
|
|
162
|
+
json=None,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_request_headers_override_and_extend_default_headers():
|
|
167
|
+
transport = create_autospec(
|
|
168
|
+
spec=transport_protocol.TransportProtocol, instance=True
|
|
169
|
+
)
|
|
170
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
171
|
+
transport.request.return_value = raw_response_mock
|
|
172
|
+
headers = {"Authorization": "Bearer abc", "X-App": "Mobile"}
|
|
173
|
+
request_headers = {"X-Request-ID": "123", "X-App": "Web"}
|
|
174
|
+
expected_headers = {
|
|
175
|
+
"Authorization": "Bearer abc",
|
|
176
|
+
"X-Request-ID": "123",
|
|
177
|
+
"X-App": "Web",
|
|
178
|
+
}
|
|
179
|
+
client = api_client.ApiClient(
|
|
180
|
+
"http://google.com", transport=transport, headers=headers
|
|
181
|
+
)
|
|
182
|
+
client.get("/orders", headers=request_headers)
|
|
183
|
+
transport.request.assert_called_once_with(
|
|
184
|
+
method="GET",
|
|
185
|
+
url="http://google.com/orders",
|
|
186
|
+
params=None,
|
|
187
|
+
headers=expected_headers,
|
|
188
|
+
json=None,
|
|
189
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
from spud import ApiResponse, response_checks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_api_response_lazily_caches_response_checks():
|
|
7
|
+
raw_response_mock = Mock()
|
|
8
|
+
response = ApiResponse(raw_response_mock)
|
|
9
|
+
check = response.check
|
|
10
|
+
assert isinstance(check, response_checks.ResponseChecks)
|
|
11
|
+
assert check.response is response
|
|
12
|
+
assert response.check is check
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from unittest.mock import Mock, create_autospec
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spud import (
|
|
6
|
+
api_client,
|
|
7
|
+
api_response,
|
|
8
|
+
endpoint,
|
|
9
|
+
raw_response_protocol,
|
|
10
|
+
transport_protocol,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_endpoint_stores_provided_api_client():
|
|
15
|
+
client = api_client.ApiClient(base_url="https://google.com")
|
|
16
|
+
endpoint_test = endpoint.Endpoint(client=client)
|
|
17
|
+
assert endpoint_test.client is client
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_endpoint_subclass_requires_path():
|
|
21
|
+
with pytest.raises(TypeError):
|
|
22
|
+
|
|
23
|
+
class BrokenEndpoint(endpoint.Endpoint):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_endpoint_path_must_be_string():
|
|
28
|
+
with pytest.raises(TypeError):
|
|
29
|
+
|
|
30
|
+
class BrokenEndpoint(endpoint.Endpoint):
|
|
31
|
+
path = 123
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_endpoint_subclass_has_inherits_path():
|
|
35
|
+
class OrdersEndpoint(endpoint.Endpoint):
|
|
36
|
+
path = "/orders"
|
|
37
|
+
|
|
38
|
+
class EndpointSubclass(OrdersEndpoint):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
assert EndpointSubclass.path == OrdersEndpoint.path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_usability():
|
|
45
|
+
class OrdersEndpoint(endpoint.Endpoint):
|
|
46
|
+
path = "/orders"
|
|
47
|
+
|
|
48
|
+
def get_order(self, order_id):
|
|
49
|
+
path = self.path + "/" + order_id
|
|
50
|
+
response = self.client.get(path=path)
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
transport = create_autospec(
|
|
54
|
+
spec=transport_protocol.TransportProtocol, instance=True
|
|
55
|
+
)
|
|
56
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
57
|
+
transport.request.return_value = raw_response_mock
|
|
58
|
+
client = api_client.ApiClient(base_url="http://google.com", transport=transport)
|
|
59
|
+
orders = OrdersEndpoint(client)
|
|
60
|
+
response = orders.get_order("123")
|
|
61
|
+
transport.request.assert_called_once_with(
|
|
62
|
+
method="GET",
|
|
63
|
+
url="http://google.com/orders/123",
|
|
64
|
+
params=None,
|
|
65
|
+
headers=None,
|
|
66
|
+
json=None,
|
|
67
|
+
)
|
|
68
|
+
assert isinstance(response, api_response.ApiResponse)
|
|
69
|
+
assert response.raw is raw_response_mock
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from unittest.mock import Mock
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from spud import httpx_transport
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeResponse:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.headers = {}
|
|
12
|
+
|
|
13
|
+
status_code = 200
|
|
14
|
+
text = "text"
|
|
15
|
+
|
|
16
|
+
def json(self) -> Any:
|
|
17
|
+
return {"status": "ok"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_request_httpx(monkeypatch):
|
|
21
|
+
url = "http://google.com/"
|
|
22
|
+
headers = {"Authorization": "Bearer Token..."}
|
|
23
|
+
params = {"q": 123}
|
|
24
|
+
json = {"status": "ok"}
|
|
25
|
+
request_httpx_mock = Mock()
|
|
26
|
+
fake_response = FakeResponse()
|
|
27
|
+
request_httpx_mock.return_value = fake_response
|
|
28
|
+
monkeypatch.setattr(httpx, "request", request_httpx_mock)
|
|
29
|
+
transport = httpx_transport.HttpxTransport()
|
|
30
|
+
response = transport.request(
|
|
31
|
+
method="POST", url=url, headers=headers, params=params, json=json
|
|
32
|
+
)
|
|
33
|
+
request_httpx_mock.assert_called_once_with(
|
|
34
|
+
method="POST", url=url, headers=headers, params=params, json=json
|
|
35
|
+
)
|
|
36
|
+
assert response is fake_response
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spud import api_response, exceptions, raw_response_protocol
|
|
6
|
+
from spud.response_checks import parse_json_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_check_status_passes_when_status_matches():
|
|
10
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
11
|
+
raw_response_mock.status_code = 200
|
|
12
|
+
|
|
13
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
14
|
+
|
|
15
|
+
response.check.status(200)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_check_status_error_when_status_not_matches():
|
|
19
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
20
|
+
raw_response_mock.status_code = 500
|
|
21
|
+
|
|
22
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
23
|
+
|
|
24
|
+
with pytest.raises(
|
|
25
|
+
exceptions.SpudCheckError,
|
|
26
|
+
match=r"^Expected status 200, got 500 Internal Server Error",
|
|
27
|
+
):
|
|
28
|
+
response.check.status(200)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_check_got_uncommon_status():
|
|
32
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
33
|
+
raw_response_mock.status_code = 599
|
|
34
|
+
|
|
35
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
36
|
+
|
|
37
|
+
with pytest.raises(
|
|
38
|
+
exceptions.SpudCheckError, match=r"^Expected status 200, got 599$"
|
|
39
|
+
):
|
|
40
|
+
response.check.status(200)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_json_path_checks_nested_field():
|
|
44
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
45
|
+
raw_response_mock.json.return_value = {"customer": {"name": "Alex"}}
|
|
46
|
+
|
|
47
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
48
|
+
|
|
49
|
+
response.check.json_path("customer.name", "Alex")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_json_path_not_have_field():
|
|
53
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
54
|
+
raw_response_mock.json.return_value = {"customer": {"name": "Alex"}}
|
|
55
|
+
|
|
56
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
57
|
+
|
|
58
|
+
with pytest.raises(
|
|
59
|
+
exceptions.SpudCheckError, match=r"^JSON path 'customer\.age' not found$"
|
|
60
|
+
):
|
|
61
|
+
response.check.json_path("customer.age", 25)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_json_path_raises_when_intermediate_value_is_none():
|
|
65
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
66
|
+
raw_response_mock.json.return_value = {"customer": None}
|
|
67
|
+
|
|
68
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
69
|
+
|
|
70
|
+
with pytest.raises(
|
|
71
|
+
exceptions.SpudCheckError, match=r"^JSON path 'customer\.age' not found$"
|
|
72
|
+
):
|
|
73
|
+
response.check.json_path("customer.age", 25)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_json_path_accepts_none_as_expected_value():
|
|
77
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
78
|
+
raw_response_mock.json.return_value = {"customer": None}
|
|
79
|
+
|
|
80
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
81
|
+
|
|
82
|
+
response.check.json_path(path="customer", expected=None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_json_checks_array_element():
|
|
86
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
87
|
+
raw_response_mock.json.return_value = {
|
|
88
|
+
"orders": [{"id": 101, "status": "created"}, {"id": 102, "status": "paid"}]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
92
|
+
|
|
93
|
+
response.check.json_path(path="orders[0].status", expected="created")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_parse_json_path_with_nested_keys():
|
|
97
|
+
result = parse_json_path("customer.name")
|
|
98
|
+
assert result == ["customer", "name"]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_parse_json_path_with_array_index():
|
|
102
|
+
result = parse_json_path("orders[0].status")
|
|
103
|
+
assert result == ["orders", 0, "status"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_json_path_raises_when_index_out_of_range():
|
|
107
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
108
|
+
raw_response_mock.json.return_value = {"orders": [{"id": 101}]}
|
|
109
|
+
|
|
110
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
111
|
+
|
|
112
|
+
with pytest.raises(
|
|
113
|
+
exceptions.SpudCheckError, match=r"^JSON path 'orders\[5\]\.id' not found$"
|
|
114
|
+
):
|
|
115
|
+
response.check.json_path(path="orders[5].id", expected=101)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_json_path_checks_included_array():
|
|
119
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
120
|
+
raw_response_mock.json.return_value = {
|
|
121
|
+
"orders": [{"items": [{"id": 101}, {"id": 102}]}]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
125
|
+
|
|
126
|
+
response.check.json_path(path="orders[0].items[1].id", expected=102)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_json_path_included_matrix():
|
|
130
|
+
result = parse_json_path("matrix[0][1]")
|
|
131
|
+
assert result == ["matrix", 0, 1]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_json_path_raises_value_error():
|
|
135
|
+
with pytest.raises(
|
|
136
|
+
ValueError,
|
|
137
|
+
match=r"^Invalid JSON path 'orders\[abc\]': array index must be an integer$",
|
|
138
|
+
):
|
|
139
|
+
parse_json_path("orders[abc]")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_json_path_raises_with_empty_index():
|
|
143
|
+
with pytest.raises(
|
|
144
|
+
ValueError,
|
|
145
|
+
match=r"^Invalid JSON path 'orders\[\]': array index cannot be empty$",
|
|
146
|
+
):
|
|
147
|
+
parse_json_path("orders[]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_json_path_raises_with_unclosed_brackets():
|
|
151
|
+
with pytest.raises(
|
|
152
|
+
ValueError, match=r"^Invalid JSON path 'orders\[0': unclosed bracket$"
|
|
153
|
+
):
|
|
154
|
+
parse_json_path("orders[0")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_json_path_raises_with_unexpected_closing_brackets():
|
|
158
|
+
with pytest.raises(
|
|
159
|
+
ValueError, match=r"^Invalid JSON path 'orders0\]': unexpected closing bracket$"
|
|
160
|
+
):
|
|
161
|
+
parse_json_path("orders0]")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_json_parse_without_separator_after_closing_bracket():
|
|
165
|
+
with pytest.raises(
|
|
166
|
+
ValueError,
|
|
167
|
+
match=r"^Invalid JSON path 'order\[0\]status': expected separator after closing bracket$",
|
|
168
|
+
):
|
|
169
|
+
parse_json_path("order[0]status")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_json_parse_supports_negative_index():
|
|
173
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
174
|
+
raw_response_mock.json.return_value = {
|
|
175
|
+
"orders": [{"id": 101}, {"id": 102}, {"id": 103}]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
179
|
+
|
|
180
|
+
response.check.json_path(path="orders[-1].id", expected=103)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_json_parse_raises_for_negative_index_out_of_range():
|
|
184
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
185
|
+
raw_response_mock.json.return_value = {
|
|
186
|
+
"orders": [{"id": 101}, {"id": 102}, {"id": 103}]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
190
|
+
|
|
191
|
+
with pytest.raises(exceptions.SpudCheckError):
|
|
192
|
+
response.check.json_path(path="orders[-4].id", expected=103)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_json_parse_support_omitted_path():
|
|
196
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
197
|
+
raw_response_mock.json.return_value = {"orders": [{"id": 101}, {"id": 102}]}
|
|
198
|
+
|
|
199
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
200
|
+
|
|
201
|
+
response.check.json_path(expected={"orders": [{"id": 101}, {"id": 102}]})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_json_parse_raises_without_required_argument():
|
|
205
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
206
|
+
raw_response_mock.json.return_value = {"orders": [{"id": 101}, {"id": 102}]}
|
|
207
|
+
|
|
208
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
209
|
+
|
|
210
|
+
with pytest.raises(
|
|
211
|
+
TypeError, match=r"^json_path\(\) missing required argument: expected$"
|
|
212
|
+
):
|
|
213
|
+
response.check.json_path()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_json_parse_raises_with_double_dot_in_path():
|
|
217
|
+
with pytest.raises(
|
|
218
|
+
ValueError, match=r"^Invalid JSON path 'user..name': empty path segment$"
|
|
219
|
+
):
|
|
220
|
+
parse_json_path("user..name")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_json_parse_raises_when_path_ended_with_dot():
|
|
224
|
+
with pytest.raises(
|
|
225
|
+
ValueError, match=r"^Invalid JSON path 'user.': empty path segment$"
|
|
226
|
+
):
|
|
227
|
+
parse_json_path("user.")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_json_parse_raises_when_path_has_bracket_inside_bracket():
|
|
231
|
+
with pytest.raises(
|
|
232
|
+
ValueError,
|
|
233
|
+
match=r"^Invalid JSON path 'order\[\[0\]\]': unexpected opening bracket$",
|
|
234
|
+
):
|
|
235
|
+
parse_json_path("order[[0]]")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_json_parse_raises_without_separator():
|
|
239
|
+
with pytest.raises(
|
|
240
|
+
ValueError,
|
|
241
|
+
match=r"^Invalid JSON path 'orders.\[0\]': unexpected opening bracket$",
|
|
242
|
+
):
|
|
243
|
+
parse_json_path("orders.[0]")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_json_parse_with_double_dots():
|
|
247
|
+
with pytest.raises(
|
|
248
|
+
ValueError,
|
|
249
|
+
match=r"^Invalid JSON path 'orders\[0\]..status': empty path segment$",
|
|
250
|
+
):
|
|
251
|
+
parse_json_path("orders[0]..status")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_json_parse_raises_when_path_starts_with_dot():
|
|
255
|
+
with pytest.raises(
|
|
256
|
+
ValueError, match=r"^Invalid JSON path '.user': empty path segment$"
|
|
257
|
+
):
|
|
258
|
+
parse_json_path(".user")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_json_parse_raises_with_float_in_path():
|
|
262
|
+
with pytest.raises(
|
|
263
|
+
ValueError,
|
|
264
|
+
match=r"^Invalid JSON path 'orders\[0.1\]': array index must be integer$",
|
|
265
|
+
):
|
|
266
|
+
parse_json_path("orders[0.1]")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_json_parse_index_correctly():
|
|
270
|
+
assert parse_json_path("[0]")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@pytest.mark.parametrize(
|
|
274
|
+
"actual, expected",
|
|
275
|
+
[
|
|
276
|
+
({"active": True}, {"active": 1}),
|
|
277
|
+
({"users": [{"active": True}]}, ({"users": [{"active": 1}]})),
|
|
278
|
+
(1, 1.0),
|
|
279
|
+
],
|
|
280
|
+
)
|
|
281
|
+
def test_json_path_raises_with_type_not_equal(actual, expected):
|
|
282
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
283
|
+
raw_response_mock.json.return_value = actual
|
|
284
|
+
|
|
285
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
286
|
+
|
|
287
|
+
with pytest.raises(exceptions.SpudCheckError):
|
|
288
|
+
response.check.json_path(expected=expected)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@pytest.mark.parametrize(
|
|
292
|
+
"actual, expected",
|
|
293
|
+
[({"users": [{"active": True}]}, ({"users": [{"active": True}]}))],
|
|
294
|
+
)
|
|
295
|
+
def test_json_path_passes_with_equal_type(actual, expected):
|
|
296
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
297
|
+
raw_response_mock.json.return_value = actual
|
|
298
|
+
|
|
299
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
300
|
+
|
|
301
|
+
response.check.json_path(expected=expected)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@pytest.mark.parametrize("actual, expected", [([1, 2], [1])])
|
|
305
|
+
def test_json_path_raises_for_different_list_lengths(actual, expected):
|
|
306
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
307
|
+
raw_response_mock.json.return_value = actual
|
|
308
|
+
|
|
309
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
310
|
+
|
|
311
|
+
with pytest.raises(exceptions.SpudCheckError):
|
|
312
|
+
response.check.json_path(expected=expected)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_response_checks_support_method_chaining():
|
|
316
|
+
raw_response_mock = Mock(spec=raw_response_protocol.RawResponseProtocol)
|
|
317
|
+
raw_response_mock.status_code = 200
|
|
318
|
+
raw_response_mock.json.return_value = {"user": {"name": "Alice"}}
|
|
319
|
+
|
|
320
|
+
response = api_response.ApiResponse(raw_response_mock)
|
|
321
|
+
|
|
322
|
+
response.check.status(200).json_path("user.name", "Alice")
|
|
323
|
+
response.check.json_path("user.name", "Alice").status(200)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_spud_check_error_public_import():
|
|
327
|
+
from spud import SpudCheckError
|
|
328
|
+
from spud.exceptions import SpudCheckError as InternalSpudCheckError
|
|
329
|
+
|
|
330
|
+
assert SpudCheckError is InternalSpudCheckError
|