schemathesis 4.0.0a12__py3-none-any.whl → 4.0.1__py3-none-any.whl
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.
- schemathesis/__init__.py +9 -4
- schemathesis/auths.py +20 -30
- schemathesis/checks.py +5 -0
- schemathesis/cli/commands/run/__init__.py +9 -6
- schemathesis/cli/commands/run/handlers/output.py +13 -0
- schemathesis/cli/constants.py +1 -1
- schemathesis/config/_operations.py +16 -21
- schemathesis/config/_projects.py +5 -1
- schemathesis/core/errors.py +10 -17
- schemathesis/core/transport.py +81 -1
- schemathesis/engine/errors.py +1 -1
- schemathesis/generation/case.py +152 -28
- schemathesis/generation/hypothesis/builder.py +12 -12
- schemathesis/generation/overrides.py +11 -27
- schemathesis/generation/stateful/__init__.py +13 -0
- schemathesis/generation/stateful/state_machine.py +31 -108
- schemathesis/graphql/loaders.py +14 -4
- schemathesis/hooks.py +1 -4
- schemathesis/openapi/checks.py +82 -20
- schemathesis/openapi/generation/filters.py +9 -2
- schemathesis/openapi/loaders.py +14 -4
- schemathesis/pytest/lazy.py +4 -31
- schemathesis/pytest/plugin.py +21 -11
- schemathesis/schemas.py +153 -89
- schemathesis/specs/graphql/schemas.py +6 -6
- schemathesis/specs/openapi/_hypothesis.py +39 -14
- schemathesis/specs/openapi/checks.py +95 -34
- schemathesis/specs/openapi/expressions/nodes.py +1 -1
- schemathesis/specs/openapi/negative/__init__.py +5 -3
- schemathesis/specs/openapi/negative/mutations.py +2 -2
- schemathesis/specs/openapi/parameters.py +0 -3
- schemathesis/specs/openapi/schemas.py +6 -91
- schemathesis/specs/openapi/stateful/links.py +1 -63
- schemathesis/transport/requests.py +12 -1
- schemathesis/transport/serialization.py +0 -4
- schemathesis/transport/wsgi.py +7 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/METADATA +8 -10
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/RECORD +41 -41
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a12.dist-info → schemathesis-4.0.1.dist-info}/licenses/LICENSE +0 -0
schemathesis/generation/case.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
from dataclasses import dataclass
|
3
|
+
from dataclasses import dataclass
|
4
4
|
from typing import TYPE_CHECKING, Any, Mapping
|
5
5
|
|
6
|
+
from schemathesis import transport
|
6
7
|
from schemathesis.checks import CHECKS, CheckContext, CheckFunction, run_checks
|
7
8
|
from schemathesis.core import NOT_SET, SCHEMATHESIS_TEST_CASE_HEADER, NotSet, curl
|
8
9
|
from schemathesis.core.failures import FailureGroup, failure_report_title, format_failures
|
@@ -11,42 +12,121 @@ from schemathesis.generation import generate_random_case_id
|
|
11
12
|
from schemathesis.generation.meta import CaseMetadata
|
12
13
|
from schemathesis.generation.overrides import Override, store_components
|
13
14
|
from schemathesis.hooks import HookContext, dispatch
|
14
|
-
from schemathesis.transport.prepare import prepare_request
|
15
|
+
from schemathesis.transport.prepare import prepare_path, prepare_request
|
15
16
|
|
16
17
|
if TYPE_CHECKING:
|
18
|
+
import httpx
|
19
|
+
import requests
|
17
20
|
import requests.auth
|
18
21
|
from requests.structures import CaseInsensitiveDict
|
22
|
+
from werkzeug.test import TestResponse
|
19
23
|
|
20
24
|
from schemathesis.schemas import APIOperation
|
21
25
|
|
22
26
|
|
27
|
+
def _default_headers() -> CaseInsensitiveDict:
|
28
|
+
from requests.structures import CaseInsensitiveDict
|
29
|
+
|
30
|
+
return CaseInsensitiveDict()
|
31
|
+
|
32
|
+
|
23
33
|
@dataclass
|
24
34
|
class Case:
|
25
|
-
"""
|
35
|
+
"""Generated test case data for a single API operation."""
|
26
36
|
|
27
37
|
operation: APIOperation
|
28
38
|
method: str
|
39
|
+
"""HTTP verb (`GET`, `POST`, etc.)"""
|
29
40
|
path: str
|
30
|
-
|
31
|
-
id: str
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
41
|
+
"""Path template from schema (e.g., `/users/{user_id}`)"""
|
42
|
+
id: str
|
43
|
+
"""Random ID sent in headers for log correlation"""
|
44
|
+
path_parameters: dict[str, Any]
|
45
|
+
"""Generated path variables (e.g., `{"user_id": "123"}`)"""
|
46
|
+
headers: CaseInsensitiveDict
|
47
|
+
"""Generated HTTP headers"""
|
48
|
+
cookies: dict[str, Any]
|
49
|
+
"""Generated cookies"""
|
50
|
+
query: dict[str, Any]
|
51
|
+
"""Generated query parameters"""
|
36
52
|
# By default, there is no body, but we can't use `None` as the default value because it clashes with `null`
|
37
53
|
# which is a valid payload.
|
38
|
-
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet
|
39
|
-
|
40
|
-
media_type: str | None
|
54
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | NotSet
|
55
|
+
"""Generated request body"""
|
56
|
+
media_type: str | None
|
57
|
+
"""Media type from OpenAPI schema (e.g., "multipart/form-data")"""
|
41
58
|
|
42
|
-
meta: CaseMetadata | None
|
59
|
+
meta: CaseMetadata | None
|
43
60
|
|
44
|
-
_auth: requests.auth.AuthBase | None
|
45
|
-
_has_explicit_auth: bool
|
61
|
+
_auth: requests.auth.AuthBase | None
|
62
|
+
_has_explicit_auth: bool
|
46
63
|
|
47
|
-
|
64
|
+
__slots__ = (
|
65
|
+
"operation",
|
66
|
+
"method",
|
67
|
+
"path",
|
68
|
+
"id",
|
69
|
+
"path_parameters",
|
70
|
+
"headers",
|
71
|
+
"cookies",
|
72
|
+
"query",
|
73
|
+
"body",
|
74
|
+
"media_type",
|
75
|
+
"meta",
|
76
|
+
"_auth",
|
77
|
+
"_has_explicit_auth",
|
78
|
+
"_components",
|
79
|
+
)
|
80
|
+
|
81
|
+
def __init__(
|
82
|
+
self,
|
83
|
+
operation: APIOperation,
|
84
|
+
method: str,
|
85
|
+
path: str,
|
86
|
+
*,
|
87
|
+
id: str | None = None,
|
88
|
+
path_parameters: dict[str, Any] | None = None,
|
89
|
+
headers: CaseInsensitiveDict | None = None,
|
90
|
+
cookies: dict[str, Any] | None = None,
|
91
|
+
query: dict[str, Any] | None = None,
|
92
|
+
body: list | dict[str, Any] | str | int | float | bool | bytes | "NotSet" = NOT_SET,
|
93
|
+
media_type: str | None = None,
|
94
|
+
meta: CaseMetadata | None = None,
|
95
|
+
_auth: requests.auth.AuthBase | None = None,
|
96
|
+
_has_explicit_auth: bool = False,
|
97
|
+
) -> None:
|
98
|
+
self.operation = operation
|
99
|
+
self.method = method
|
100
|
+
self.path = path
|
101
|
+
|
102
|
+
self.id = id if id is not None else generate_random_case_id()
|
103
|
+
self.path_parameters = path_parameters if path_parameters is not None else {}
|
104
|
+
self.headers = headers if headers is not None else _default_headers()
|
105
|
+
self.cookies = cookies if cookies is not None else {}
|
106
|
+
self.query = query if query is not None else {}
|
107
|
+
self.body = body
|
108
|
+
self.media_type = media_type
|
109
|
+
self.meta = meta
|
110
|
+
self._auth = _auth
|
111
|
+
self._has_explicit_auth = _has_explicit_auth
|
48
112
|
self._components = store_components(self)
|
49
113
|
|
114
|
+
def __eq__(self, other: object) -> bool:
|
115
|
+
if not isinstance(other, Case):
|
116
|
+
return NotImplemented
|
117
|
+
|
118
|
+
return (
|
119
|
+
self.operation == other.operation
|
120
|
+
and self.method == other.method
|
121
|
+
and self.path == other.path
|
122
|
+
and self.path_parameters == other.path_parameters
|
123
|
+
and self.headers == other.headers
|
124
|
+
and self.cookies == other.cookies
|
125
|
+
and self.query == other.query
|
126
|
+
and self.body == other.body
|
127
|
+
and self.media_type == other.media_type
|
128
|
+
)
|
129
|
+
|
50
130
|
@property
|
51
131
|
def _override(self) -> Override:
|
52
132
|
return Override.from_components(self._components, self)
|
@@ -56,6 +136,8 @@ class Case:
|
|
56
136
|
first = True
|
57
137
|
for name in ("path_parameters", "headers", "cookies", "query", "body"):
|
58
138
|
value = getattr(self, name)
|
139
|
+
if name != "body" and not value:
|
140
|
+
continue
|
59
141
|
if value is not None and not isinstance(value, NotSet):
|
60
142
|
if first:
|
61
143
|
first = False
|
@@ -69,8 +151,19 @@ class Case:
|
|
69
151
|
|
70
152
|
def _repr_pretty_(self, *args: Any, **kwargs: Any) -> None: ...
|
71
153
|
|
154
|
+
@property
|
155
|
+
def formatted_path(self) -> str:
|
156
|
+
"""Path template with variables substituted (e.g., /users/{user_id} → /users/123)."""
|
157
|
+
return prepare_path(self.path, self.path_parameters)
|
158
|
+
|
72
159
|
def as_curl_command(self, headers: Mapping[str, Any] | None = None, verify: bool = True) -> str:
|
73
|
-
"""
|
160
|
+
"""Generate a curl command that reproduces this test case.
|
161
|
+
|
162
|
+
Args:
|
163
|
+
headers: Additional headers to include in the command.
|
164
|
+
verify: When False, adds `--insecure` flag to curl command.
|
165
|
+
|
166
|
+
"""
|
74
167
|
request_data = prepare_request(self, headers, config=self.operation.schema.config.output.sanitization)
|
75
168
|
return curl.generate(
|
76
169
|
method=str(request_data.method),
|
@@ -82,7 +175,6 @@ class Case:
|
|
82
175
|
)
|
83
176
|
|
84
177
|
def as_transport_kwargs(self, base_url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
85
|
-
"""Convert the test case into a dictionary acceptable by the underlying transport call."""
|
86
178
|
return self.operation.schema.transport.serialize_case(self, base_url=base_url, headers=headers)
|
87
179
|
|
88
180
|
def call(
|
@@ -94,11 +186,28 @@ class Case:
|
|
94
186
|
cookies: dict[str, Any] | None = None,
|
95
187
|
**kwargs: Any,
|
96
188
|
) -> Response:
|
189
|
+
"""Make an HTTP request using this test case's data without validation.
|
190
|
+
|
191
|
+
Use when you need to validate response separately
|
192
|
+
|
193
|
+
Args:
|
194
|
+
base_url: Override the schema's base URL.
|
195
|
+
session: Reuse an existing requests session.
|
196
|
+
headers: Additional headers.
|
197
|
+
params: Additional query parameters.
|
198
|
+
cookies: Additional cookies.
|
199
|
+
**kwargs: Additional transport-level arguments.
|
200
|
+
|
201
|
+
"""
|
97
202
|
hook_context = HookContext(operation=self.operation)
|
98
203
|
dispatch("before_call", hook_context, self, **kwargs)
|
99
204
|
if self.operation.app is not None:
|
100
|
-
kwargs
|
101
|
-
|
205
|
+
kwargs.setdefault("app", self.operation.app)
|
206
|
+
if "app" in kwargs:
|
207
|
+
transport_ = transport.get(kwargs["app"])
|
208
|
+
else:
|
209
|
+
transport_ = self.operation.schema.transport
|
210
|
+
response = transport_.send(
|
102
211
|
self,
|
103
212
|
session=session,
|
104
213
|
base_url=base_url,
|
@@ -112,26 +221,29 @@ class Case:
|
|
112
221
|
|
113
222
|
def validate_response(
|
114
223
|
self,
|
115
|
-
response: Response,
|
224
|
+
response: Response | httpx.Response | requests.Response | TestResponse,
|
116
225
|
checks: list[CheckFunction] | None = None,
|
117
226
|
additional_checks: list[CheckFunction] | None = None,
|
118
227
|
excluded_checks: list[CheckFunction] | None = None,
|
119
228
|
headers: dict[str, Any] | None = None,
|
120
229
|
transport_kwargs: dict[str, Any] | None = None,
|
121
230
|
) -> None:
|
122
|
-
"""Validate
|
231
|
+
"""Validate a response against the API schema and built-in checks.
|
123
232
|
|
124
|
-
|
233
|
+
Args:
|
234
|
+
response: Response to validate.
|
235
|
+
checks: Explicit set of checks to run.
|
236
|
+
additional_checks: Additional custom checks to run.
|
237
|
+
excluded_checks: Built-in checks to skip.
|
238
|
+
headers: Headers used in the original request.
|
239
|
+
transport_kwargs: Transport arguments used in the original request.
|
125
240
|
|
126
|
-
:param response: Application response.
|
127
|
-
:param checks: A tuple of check functions that accept ``response`` and ``case``.
|
128
|
-
:param additional_checks: A tuple of additional checks that will be executed after ones from the ``checks``
|
129
|
-
argument.
|
130
|
-
:param excluded_checks: Checks excluded from the default ones.
|
131
241
|
"""
|
132
242
|
__tracebackhide__ = True
|
133
243
|
from requests.structures import CaseInsensitiveDict
|
134
244
|
|
245
|
+
response = Response.from_any(response)
|
246
|
+
|
135
247
|
checks = [
|
136
248
|
check
|
137
249
|
for check in list(checks or CHECKS.get_all()) + list(additional_checks or [])
|
@@ -180,6 +292,18 @@ class Case:
|
|
180
292
|
excluded_checks: list[CheckFunction] | None = None,
|
181
293
|
**kwargs: Any,
|
182
294
|
) -> Response:
|
295
|
+
"""Make an HTTP request and validates the response automatically.
|
296
|
+
|
297
|
+
Args:
|
298
|
+
base_url: Override the schema's base URL.
|
299
|
+
session: Reuse an existing requests session.
|
300
|
+
headers: Additional headers to send.
|
301
|
+
checks: Explicit set of checks to run.
|
302
|
+
additional_checks: Additional custom checks to run.
|
303
|
+
excluded_checks: Built-in checks to skip.
|
304
|
+
**kwargs: Additional transport-level arguments.
|
305
|
+
|
306
|
+
"""
|
183
307
|
__tracebackhide__ = True
|
184
308
|
response = self.call(base_url, session, headers, **kwargs)
|
185
309
|
self.validate_response(
|
@@ -103,11 +103,11 @@ def create_test(
|
|
103
103
|
if config.settings is not None:
|
104
104
|
# Merge the user-provided settings with the current ones
|
105
105
|
settings = hypothesis.settings(
|
106
|
-
settings,
|
106
|
+
config.settings,
|
107
107
|
**{
|
108
|
-
item: getattr(
|
108
|
+
item: getattr(settings, item)
|
109
109
|
for item in all_settings
|
110
|
-
if getattr(
|
110
|
+
if getattr(settings, item) != getattr(default, item)
|
111
111
|
},
|
112
112
|
)
|
113
113
|
|
@@ -475,7 +475,7 @@ def _iter_coverage_cases(
|
|
475
475
|
data = template.with_body(value=value, media_type=body.media_type)
|
476
476
|
yield operation.Case(
|
477
477
|
**data.kwargs,
|
478
|
-
|
478
|
+
_meta=CaseMetadata(
|
479
479
|
generation=GenerationInfo(
|
480
480
|
time=elapsed,
|
481
481
|
mode=value.generation_mode,
|
@@ -497,7 +497,7 @@ def _iter_coverage_cases(
|
|
497
497
|
data = template.with_body(value=next_value, media_type=body.media_type)
|
498
498
|
yield operation.Case(
|
499
499
|
**data.kwargs,
|
500
|
-
|
500
|
+
_meta=CaseMetadata(
|
501
501
|
generation=GenerationInfo(
|
502
502
|
time=instant.elapsed,
|
503
503
|
mode=next_value.generation_mode,
|
@@ -518,7 +518,7 @@ def _iter_coverage_cases(
|
|
518
518
|
seen_positive.insert(data.kwargs)
|
519
519
|
yield operation.Case(
|
520
520
|
**data.kwargs,
|
521
|
-
|
521
|
+
_meta=CaseMetadata(
|
522
522
|
generation=GenerationInfo(
|
523
523
|
time=template_time,
|
524
524
|
mode=GenerationMode.POSITIVE,
|
@@ -546,7 +546,7 @@ def _iter_coverage_cases(
|
|
546
546
|
|
547
547
|
yield operation.Case(
|
548
548
|
**data.kwargs,
|
549
|
-
|
549
|
+
_meta=CaseMetadata(
|
550
550
|
generation=GenerationInfo(time=instant.elapsed, mode=value.generation_mode),
|
551
551
|
components=data.components,
|
552
552
|
phase=PhaseInfo.coverage(
|
@@ -566,7 +566,7 @@ def _iter_coverage_cases(
|
|
566
566
|
yield operation.Case(
|
567
567
|
**data.kwargs,
|
568
568
|
method=method.upper(),
|
569
|
-
|
569
|
+
_meta=CaseMetadata(
|
570
570
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
571
571
|
components=data.components,
|
572
572
|
phase=PhaseInfo.coverage(description=f"Unspecified HTTP method: {method.upper()}"),
|
@@ -588,7 +588,7 @@ def _iter_coverage_cases(
|
|
588
588
|
)
|
589
589
|
yield operation.Case(
|
590
590
|
**data.kwargs,
|
591
|
-
|
591
|
+
_meta=CaseMetadata(
|
592
592
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
593
593
|
components=data.components,
|
594
594
|
phase=PhaseInfo.coverage(
|
@@ -613,7 +613,7 @@ def _iter_coverage_cases(
|
|
613
613
|
)
|
614
614
|
yield operation.Case(
|
615
615
|
**data.kwargs,
|
616
|
-
|
616
|
+
_meta=CaseMetadata(
|
617
617
|
generation=GenerationInfo(time=instant.elapsed, mode=GenerationMode.NEGATIVE),
|
618
618
|
components=data.components,
|
619
619
|
phase=PhaseInfo.coverage(
|
@@ -655,7 +655,7 @@ def _iter_coverage_cases(
|
|
655
655
|
)
|
656
656
|
return operation.Case(
|
657
657
|
**data.kwargs,
|
658
|
-
|
658
|
+
_meta=CaseMetadata(
|
659
659
|
generation=GenerationInfo(
|
660
660
|
time=_instant.elapsed,
|
661
661
|
mode=_generation_mode,
|
@@ -781,7 +781,7 @@ def _case_to_kwargs(case: Case) -> dict:
|
|
781
781
|
kwargs = {}
|
782
782
|
for container_name in LOCATION_TO_CONTAINER.values():
|
783
783
|
value = getattr(case, container_name)
|
784
|
-
if isinstance(value, CaseInsensitiveDict):
|
784
|
+
if isinstance(value, CaseInsensitiveDict) and value:
|
785
785
|
kwargs[container_name] = dict(value)
|
786
786
|
elif value and value is not NOT_SET:
|
787
787
|
kwargs[container_name] = value
|
@@ -2,17 +2,15 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
from collections.abc import Mapping
|
4
4
|
from dataclasses import dataclass
|
5
|
-
from typing import TYPE_CHECKING, Any,
|
5
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
6
6
|
|
7
7
|
from schemathesis.config import ProjectConfig
|
8
|
-
from schemathesis.core.errors import IncorrectUsage
|
9
|
-
from schemathesis.core.marks import Mark
|
10
8
|
from schemathesis.core.transforms import diff
|
11
9
|
from schemathesis.generation.meta import ComponentKind
|
12
10
|
|
13
11
|
if TYPE_CHECKING:
|
14
12
|
from schemathesis.generation.case import Case
|
15
|
-
from schemathesis.schemas import APIOperation, Parameter
|
13
|
+
from schemathesis.schemas import APIOperation, Parameter
|
16
14
|
|
17
15
|
|
18
16
|
@dataclass
|
@@ -24,13 +22,15 @@ class Override:
|
|
24
22
|
cookies: dict[str, str]
|
25
23
|
path_parameters: dict[str, str]
|
26
24
|
|
27
|
-
def
|
28
|
-
|
29
|
-
"query"
|
30
|
-
"headers"
|
31
|
-
"cookies"
|
32
|
-
"path_parameters"
|
33
|
-
|
25
|
+
def items(self) -> Iterator[tuple[str, dict[str, str]]]:
|
26
|
+
for key, value in (
|
27
|
+
("query", self.query),
|
28
|
+
("headers", self.headers),
|
29
|
+
("cookies", self.cookies),
|
30
|
+
("path_parameters", self.path_parameters),
|
31
|
+
):
|
32
|
+
if value:
|
33
|
+
yield key, value
|
34
34
|
|
35
35
|
@classmethod
|
36
36
|
def from_components(cls, components: dict[ComponentKind, StoredValue], case: Case) -> Override:
|
@@ -77,14 +77,6 @@ def _get_override_value(param: Parameter, parameters: dict[str, Any]) -> Any:
|
|
77
77
|
return None
|
78
78
|
|
79
79
|
|
80
|
-
def _for_parameters(overridden: dict[str, str], defined: ParameterSet) -> dict[str, str]:
|
81
|
-
output = {}
|
82
|
-
for param in defined:
|
83
|
-
if param.name in overridden:
|
84
|
-
output[param.name] = overridden[param.name]
|
85
|
-
return output
|
86
|
-
|
87
|
-
|
88
80
|
@dataclass
|
89
81
|
class StoredValue:
|
90
82
|
value: dict[str, Any] | None
|
@@ -122,11 +114,3 @@ def store_components(case: Case) -> dict[ComponentKind, StoredValue]:
|
|
122
114
|
ComponentKind.PATH_PARAMETERS,
|
123
115
|
]
|
124
116
|
}
|
125
|
-
|
126
|
-
|
127
|
-
OverrideMark = Mark[Override](attr_name="override")
|
128
|
-
|
129
|
-
|
130
|
-
def check_no_override_mark(test: Callable) -> None:
|
131
|
-
if OverrideMark.is_set(test):
|
132
|
-
raise IncorrectUsage(f"`{test.__name__}` has already been decorated with `override`.")
|
@@ -7,6 +7,19 @@ if TYPE_CHECKING:
|
|
7
7
|
|
8
8
|
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
9
9
|
|
10
|
+
__all__ = [
|
11
|
+
"APIStateMachine",
|
12
|
+
]
|
13
|
+
|
14
|
+
|
15
|
+
def __getattr__(name: str) -> type[APIStateMachine]:
|
16
|
+
if name == "APIStateMachine":
|
17
|
+
from schemathesis.generation.stateful.state_machine import APIStateMachine
|
18
|
+
|
19
|
+
return APIStateMachine
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
21
|
+
|
22
|
+
|
10
23
|
STATEFUL_TESTS_LABEL = "Stateful tests"
|
11
24
|
|
12
25
|
|
@@ -129,9 +129,10 @@ def _normalize_name(name: str) -> str:
|
|
129
129
|
|
130
130
|
|
131
131
|
class APIStateMachine(RuleBasedStateMachine):
|
132
|
-
"""
|
132
|
+
"""State machine for executing API operation sequences based on OpenAPI links.
|
133
133
|
|
134
|
-
|
134
|
+
Automatically generates test scenarios by chaining API operations according
|
135
|
+
to their defined relationships in the schema.
|
135
136
|
"""
|
136
137
|
|
137
138
|
# This is a convenience attribute, which happened to clash with `RuleBasedStateMachine` instance level attribute
|
@@ -193,17 +194,22 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
193
194
|
|
194
195
|
@classmethod
|
195
196
|
def run(cls, *, settings: hypothesis.settings | None = None) -> None:
|
196
|
-
"""
|
197
|
+
"""Execute the state machine test scenarios.
|
198
|
+
|
199
|
+
Args:
|
200
|
+
settings: Hypothesis settings for test execution.
|
201
|
+
|
202
|
+
"""
|
197
203
|
from . import run_state_machine_as_test
|
198
204
|
|
199
205
|
__tracebackhide__ = True
|
200
206
|
return run_state_machine_as_test(cls, settings=settings)
|
201
207
|
|
202
208
|
def setup(self) -> None:
|
203
|
-
"""
|
209
|
+
"""Called once at the beginning of each test scenario."""
|
204
210
|
|
205
211
|
def teardown(self) -> None:
|
206
|
-
|
212
|
+
"""Called once at the end of each test scenario."""
|
207
213
|
|
208
214
|
# To provide the return type in the rendered documentation
|
209
215
|
teardown.__doc__ = RuleBasedStateMachine.teardown.__doc__
|
@@ -213,14 +219,6 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
213
219
|
return self.step(input)
|
214
220
|
|
215
221
|
def step(self, input: StepInput) -> StepOutput:
|
216
|
-
"""A single state machine step.
|
217
|
-
|
218
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
219
|
-
:param previous: Optional result from the previous step and the direction in which this step should be done.
|
220
|
-
|
221
|
-
Schemathesis prepares data, makes a call and validates the received response.
|
222
|
-
It is the most high-level point to extend the testing process. You probably don't need it in most cases.
|
223
|
-
"""
|
224
222
|
__tracebackhide__ = True
|
225
223
|
self.before_call(input.case)
|
226
224
|
kwargs = self.get_call_kwargs(input.case)
|
@@ -230,126 +228,51 @@ class APIStateMachine(RuleBasedStateMachine):
|
|
230
228
|
return StepOutput(response, input.case)
|
231
229
|
|
232
230
|
def before_call(self, case: Case) -> None:
|
233
|
-
"""
|
234
|
-
|
235
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
236
|
-
|
237
|
-
Use it if you want to inject static data, for example,
|
238
|
-
a query parameter that should always be used in API calls:
|
231
|
+
"""Called before each API operation in the scenario.
|
239
232
|
|
240
|
-
|
233
|
+
Args:
|
234
|
+
case: Test case data for the operation.
|
241
235
|
|
242
|
-
class APIWorkflow(schema.as_state_machine()):
|
243
|
-
def before_call(self, case):
|
244
|
-
case.query = case.query or {}
|
245
|
-
case.query["test"] = "true"
|
246
|
-
|
247
|
-
You can also modify data only for some operations:
|
248
|
-
|
249
|
-
.. code-block:: python
|
250
|
-
|
251
|
-
class APIWorkflow(schema.as_state_machine()):
|
252
|
-
def before_call(self, case):
|
253
|
-
if case.method == "PUT" and case.path == "/items":
|
254
|
-
case.body["is_fake"] = True
|
255
236
|
"""
|
256
237
|
|
257
238
|
def after_call(self, response: Response, case: Case) -> None:
|
258
|
-
"""
|
259
|
-
|
260
|
-
:param response: Response from the application under test.
|
261
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
262
|
-
|
263
|
-
For example, you can log all response statuses by using this hook:
|
264
|
-
|
265
|
-
.. code-block:: python
|
266
|
-
|
267
|
-
import logging
|
239
|
+
"""Called after each API operation in the scenario.
|
268
240
|
|
269
|
-
|
270
|
-
|
241
|
+
Args:
|
242
|
+
response: HTTP response from the operation.
|
243
|
+
case: Test case data that was executed.
|
271
244
|
|
272
|
-
|
273
|
-
class APIWorkflow(schema.as_state_machine()):
|
274
|
-
def after_call(self, response, case):
|
275
|
-
logger.info(
|
276
|
-
"%s %s -> %d",
|
277
|
-
case.method,
|
278
|
-
case.path,
|
279
|
-
response.status_code,
|
280
|
-
)
|
281
|
-
|
282
|
-
|
283
|
-
# POST /users/ -> 201
|
284
|
-
# GET /users/{user_id} -> 200
|
285
|
-
# PATCH /users/{user_id} -> 200
|
286
|
-
# GET /users/{user_id} -> 200
|
287
|
-
# PATCH /users/{user_id} -> 500
|
288
245
|
"""
|
289
246
|
|
290
247
|
def call(self, case: Case, **kwargs: Any) -> Response:
|
291
|
-
"""Make a request to the API.
|
292
|
-
|
293
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
294
|
-
:param kwargs: Keyword arguments that will be passed to the appropriate ``case.call_*`` method.
|
295
|
-
:return: Response from the application under test.
|
296
|
-
|
297
|
-
Note that WSGI/ASGI applications are detected automatically in this method. Depending on the result of this
|
298
|
-
detection the state machine will call the ``call`` method.
|
299
|
-
|
300
|
-
Usually, you don't need to override this method unless you are building a different state machine on top of this
|
301
|
-
one and want to customize the transport layer itself.
|
302
|
-
"""
|
303
248
|
return case.call(**kwargs)
|
304
249
|
|
305
250
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
306
|
-
"""
|
307
|
-
|
308
|
-
Mostly they are proxied to the :func:`requests.request` call.
|
251
|
+
"""Returns keyword arguments for the API call.
|
309
252
|
|
310
|
-
:
|
253
|
+
Args:
|
254
|
+
case: Test case being executed.
|
311
255
|
|
312
|
-
|
256
|
+
Returns:
|
257
|
+
Dictionary passed to the `case.call()` method.
|
313
258
|
|
314
|
-
class APIWorkflow(schema.as_state_machine()):
|
315
|
-
def get_call_kwargs(self, case):
|
316
|
-
return {"verify": False}
|
317
|
-
|
318
|
-
The above example disables the server's TLS certificate verification.
|
319
259
|
"""
|
320
260
|
return {}
|
321
261
|
|
322
262
|
def validate_response(
|
323
263
|
self, response: Response, case: Case, additional_checks: list[CheckFunction] | None = None, **kwargs: Any
|
324
264
|
) -> None:
|
325
|
-
"""
|
326
|
-
|
327
|
-
:param response: Response from the application under test.
|
328
|
-
:param Case case: Generated test case data that should be sent in an API call to the tested API operation.
|
329
|
-
:param additional_checks: A list of checks that will be run together with the default ones.
|
330
|
-
:raises FailureGroup: If any of the supplied checks failed.
|
331
|
-
|
332
|
-
If you need to change the default checks or provide custom validation rules, you can do it here.
|
333
|
-
|
334
|
-
.. code-block:: python
|
335
|
-
|
336
|
-
def my_check(response, case):
|
337
|
-
... # some assertions
|
338
|
-
|
339
|
-
|
340
|
-
class APIWorkflow(schema.as_state_machine()):
|
341
|
-
def validate_response(self, response, case):
|
342
|
-
case.validate_response(response, checks=(my_check,))
|
265
|
+
"""Validates the API response using configured checks.
|
343
266
|
|
344
|
-
|
345
|
-
|
267
|
+
Args:
|
268
|
+
response: HTTP response to validate.
|
269
|
+
case: Test case that generated the response.
|
270
|
+
additional_checks: Extra validation functions to run.
|
271
|
+
kwargs: Transport-level keyword arguments.
|
346
272
|
|
347
|
-
|
348
|
-
|
273
|
+
Raises:
|
274
|
+
FailureGroup: When validation checks fail.
|
349
275
|
|
350
|
-
**Note** that it is preferred to pass check functions as an argument to ``case.validate_response``.
|
351
|
-
In this case, all checks will be executed, and you'll receive a grouped exception that contains results from
|
352
|
-
all provided checks rather than only the first encountered exception.
|
353
276
|
"""
|
354
277
|
__tracebackhide__ = True
|
355
278
|
case.validate_response(response, additional_checks=additional_checks, transport_kwargs=kwargs)
|