schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a2__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/checks.py +6 -4
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +4 -9
- schemathesis/cli/commands/run/executor.py +6 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +765 -143
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +1 -0
- schemathesis/engine/phases/stateful/_executor.py +19 -44
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +8 -3
- schemathesis/generation/stateful/state_machine.py +53 -36
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +126 -119
- schemathesis/specs/openapi/schemas.py +18 -22
- schemathesis/specs/openapi/stateful/__init__.py +77 -55
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/RECORD +34 -35
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
@@ -256,7 +256,7 @@ def convert_experimental(
|
|
256
256
|
]
|
257
257
|
|
258
258
|
|
259
|
-
def
|
259
|
+
def reduce_list(ctx: click.core.Context, param: click.core.Parameter, value: tuple[list[str]]) -> list[str]:
|
260
260
|
return reduce(operator.iadd, value, [])
|
261
261
|
|
262
262
|
|
schemathesis/cli/ext/options.py
CHANGED
@@ -80,7 +80,10 @@ class RegistryChoice(BaseCsvChoice):
|
|
80
80
|
def convert( # type: ignore[return]
|
81
81
|
self, value: str, param: click.core.Parameter | None, ctx: click.core.Context | None
|
82
82
|
) -> list[str]:
|
83
|
-
|
83
|
+
selected, invalid_options = self.parse_value(value)
|
84
|
+
if not invalid_options and selected:
|
85
|
+
return selected
|
86
|
+
self.fail_on_invalid_options(invalid_options, selected)
|
84
87
|
|
85
88
|
|
86
89
|
class OptionalInt(click.types.IntRange):
|
schemathesis/core/failures.py
CHANGED
@@ -2,11 +2,12 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import http.client
|
4
4
|
import textwrap
|
5
|
+
import traceback
|
5
6
|
from collections.abc import Sequence
|
6
7
|
from dataclasses import dataclass
|
7
8
|
from enum import Enum, auto
|
8
9
|
from json import JSONDecodeError
|
9
|
-
from typing import Callable
|
10
|
+
from typing import Any, Callable
|
10
11
|
|
11
12
|
from schemathesis.core.compat import BaseExceptionGroup
|
12
13
|
from schemathesis.core.output import OutputConfig, prepare_response_payload
|
@@ -32,7 +33,7 @@ class Severity(Enum):
|
|
32
33
|
class Failure(AssertionError):
|
33
34
|
"""API check failure."""
|
34
35
|
|
35
|
-
__slots__ = ("operation", "title", "message", "
|
36
|
+
__slots__ = ("operation", "title", "message", "case_id", "severity")
|
36
37
|
|
37
38
|
def __init__(
|
38
39
|
self,
|
@@ -40,14 +41,12 @@ class Failure(AssertionError):
|
|
40
41
|
operation: str,
|
41
42
|
title: str,
|
42
43
|
message: str,
|
43
|
-
code: str,
|
44
44
|
case_id: str | None = None,
|
45
45
|
severity: Severity = Severity.MEDIUM,
|
46
46
|
) -> None:
|
47
47
|
self.operation = operation
|
48
48
|
self.title = title
|
49
49
|
self.message = message
|
50
|
-
self.code = code
|
51
50
|
self.case_id = case_id
|
52
51
|
self.severity = severity
|
53
52
|
|
@@ -72,20 +71,58 @@ class Failure(AssertionError):
|
|
72
71
|
return NotImplemented
|
73
72
|
return type(self) is type(other) and self.operation == other.operation and self._unique_key == other._unique_key
|
74
73
|
|
75
|
-
@classmethod
|
76
|
-
def from_assertion(cls, *, name: str, operation: str, exc: AssertionError) -> Failure:
|
77
|
-
return Failure(
|
78
|
-
operation=operation,
|
79
|
-
title=f"Custom check failed: `{name}`",
|
80
|
-
message=str(exc),
|
81
|
-
code="custom",
|
82
|
-
)
|
83
|
-
|
84
74
|
@property
|
85
|
-
def _unique_key(self) ->
|
75
|
+
def _unique_key(self) -> Any:
|
86
76
|
return self.message
|
87
77
|
|
88
78
|
|
79
|
+
def get_origin(exception: BaseException, seen: tuple[BaseException, ...] = ()) -> tuple:
|
80
|
+
filename, lineno = None, None
|
81
|
+
if tb := exception.__traceback__:
|
82
|
+
filename, lineno, *_ = traceback.extract_tb(tb)[-1]
|
83
|
+
seen = (*seen, exception)
|
84
|
+
context = ()
|
85
|
+
if exception.__context__ is not None and exception.__context__ not in seen:
|
86
|
+
context = get_origin(exception.__context__, seen=seen)
|
87
|
+
return (
|
88
|
+
type(exception),
|
89
|
+
filename,
|
90
|
+
lineno,
|
91
|
+
context,
|
92
|
+
(
|
93
|
+
tuple(get_origin(exc, seen=seen) for exc in exception.exceptions if exc not in seen)
|
94
|
+
if isinstance(exception, BaseExceptionGroup)
|
95
|
+
else ()
|
96
|
+
),
|
97
|
+
)
|
98
|
+
|
99
|
+
|
100
|
+
class CustomFailure(Failure):
|
101
|
+
__slots__ = ("operation", "title", "message", "exception", "case_id", "severity", "origin")
|
102
|
+
|
103
|
+
def __init__(
|
104
|
+
self,
|
105
|
+
*,
|
106
|
+
operation: str,
|
107
|
+
title: str,
|
108
|
+
message: str,
|
109
|
+
exception: AssertionError,
|
110
|
+
case_id: str | None = None,
|
111
|
+
severity: Severity = Severity.MEDIUM,
|
112
|
+
) -> None:
|
113
|
+
self.operation = operation
|
114
|
+
self.title = title
|
115
|
+
self.message = message
|
116
|
+
self.exception = exception
|
117
|
+
self.case_id = case_id
|
118
|
+
self.severity = severity
|
119
|
+
self.origin = get_origin(exception)
|
120
|
+
|
121
|
+
@property
|
122
|
+
def _unique_key(self) -> Any:
|
123
|
+
return self.origin
|
124
|
+
|
125
|
+
|
89
126
|
@dataclass
|
90
127
|
class MaxResponseTimeConfig:
|
91
128
|
limit: float = 10.0
|
@@ -94,7 +131,7 @@ class MaxResponseTimeConfig:
|
|
94
131
|
class ResponseTimeExceeded(Failure):
|
95
132
|
"""Response took longer than expected."""
|
96
133
|
|
97
|
-
__slots__ = ("operation", "elapsed", "deadline", "title", "message", "
|
134
|
+
__slots__ = ("operation", "elapsed", "deadline", "title", "message", "case_id", "severity")
|
98
135
|
|
99
136
|
def __init__(
|
100
137
|
self,
|
@@ -104,7 +141,6 @@ class ResponseTimeExceeded(Failure):
|
|
104
141
|
deadline: int,
|
105
142
|
message: str,
|
106
143
|
title: str = "Response time limit exceeded",
|
107
|
-
code: str = "response_time_exceeded",
|
108
144
|
case_id: str | None = None,
|
109
145
|
) -> None:
|
110
146
|
self.operation = operation
|
@@ -112,7 +148,6 @@ class ResponseTimeExceeded(Failure):
|
|
112
148
|
self.deadline = deadline
|
113
149
|
self.title = title
|
114
150
|
self.message = message
|
115
|
-
self.code = code
|
116
151
|
self.case_id = case_id
|
117
152
|
self.severity = Severity.LOW
|
118
153
|
|
@@ -124,7 +159,7 @@ class ResponseTimeExceeded(Failure):
|
|
124
159
|
class ServerError(Failure):
|
125
160
|
"""Server responded with an error."""
|
126
161
|
|
127
|
-
__slots__ = ("operation", "status_code", "title", "message", "
|
162
|
+
__slots__ = ("operation", "status_code", "title", "message", "case_id", "severity")
|
128
163
|
|
129
164
|
def __init__(
|
130
165
|
self,
|
@@ -133,14 +168,12 @@ class ServerError(Failure):
|
|
133
168
|
status_code: int,
|
134
169
|
title: str = "Server error",
|
135
170
|
message: str = "",
|
136
|
-
code: str = "server_error",
|
137
171
|
case_id: str | None = None,
|
138
172
|
) -> None:
|
139
173
|
self.operation = operation
|
140
174
|
self.status_code = status_code
|
141
175
|
self.title = title
|
142
176
|
self.message = message
|
143
|
-
self.code = code
|
144
177
|
self.case_id = case_id
|
145
178
|
self.severity = Severity.CRITICAL
|
146
179
|
|
@@ -161,7 +194,6 @@ class MalformedJson(Failure):
|
|
161
194
|
"colno",
|
162
195
|
"message",
|
163
196
|
"title",
|
164
|
-
"code",
|
165
197
|
"case_id",
|
166
198
|
"severity",
|
167
199
|
)
|
@@ -177,7 +209,6 @@ class MalformedJson(Failure):
|
|
177
209
|
colno: int,
|
178
210
|
message: str,
|
179
211
|
title: str = "JSON deserialization error",
|
180
|
-
code: str = "malformed_json",
|
181
212
|
case_id: str | None = None,
|
182
213
|
) -> None:
|
183
214
|
self.operation = operation
|
@@ -188,12 +219,11 @@ class MalformedJson(Failure):
|
|
188
219
|
self.colno = colno
|
189
220
|
self.message = message
|
190
221
|
self.title = title
|
191
|
-
self.code = code
|
192
222
|
self.case_id = case_id
|
193
223
|
self.severity = Severity.MEDIUM
|
194
224
|
|
195
225
|
@property
|
196
|
-
def _unique_key(self) ->
|
226
|
+
def _unique_key(self) -> Any:
|
197
227
|
return self.title
|
198
228
|
|
199
229
|
@classmethod
|
schemathesis/engine/core.py
CHANGED
schemathesis/engine/events.py
CHANGED
@@ -6,7 +6,6 @@ from dataclasses import dataclass
|
|
6
6
|
from typing import TYPE_CHECKING, Generator
|
7
7
|
|
8
8
|
from schemathesis.core.result import Result
|
9
|
-
from schemathesis.core.transport import Response
|
10
9
|
from schemathesis.engine.errors import EngineErrorInfo
|
11
10
|
from schemathesis.engine.phases import Phase, PhaseName
|
12
11
|
from schemathesis.engine.recorder import ScenarioRecorder
|
@@ -142,6 +141,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
142
141
|
"timestamp",
|
143
142
|
"phase",
|
144
143
|
"suite_id",
|
144
|
+
"label",
|
145
145
|
"status",
|
146
146
|
"recorder",
|
147
147
|
"elapsed_time",
|
@@ -155,6 +155,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
155
155
|
id: uuid.UUID,
|
156
156
|
phase: PhaseName,
|
157
157
|
suite_id: uuid.UUID,
|
158
|
+
label: str | None,
|
158
159
|
status: Status,
|
159
160
|
recorder: ScenarioRecorder,
|
160
161
|
elapsed_time: float,
|
@@ -165,6 +166,7 @@ class ScenarioFinished(ScenarioEvent):
|
|
165
166
|
self.timestamp = time.time()
|
166
167
|
self.phase = phase
|
167
168
|
self.suite_id = suite_id
|
169
|
+
self.label = label
|
168
170
|
self.status = status
|
169
171
|
self.recorder = recorder
|
170
172
|
self.elapsed_time = elapsed_time
|
@@ -172,102 +174,6 @@ class ScenarioFinished(ScenarioEvent):
|
|
172
174
|
self.is_final = is_final
|
173
175
|
|
174
176
|
|
175
|
-
@dataclass
|
176
|
-
class StepEvent(ScenarioEvent):
|
177
|
-
scenario_id: uuid.UUID
|
178
|
-
|
179
|
-
|
180
|
-
@dataclass
|
181
|
-
class StepStarted(StepEvent):
|
182
|
-
"""Before executing a test case."""
|
183
|
-
|
184
|
-
__slots__ = (
|
185
|
-
"id",
|
186
|
-
"timestamp",
|
187
|
-
"phase",
|
188
|
-
"suite_id",
|
189
|
-
"scenario_id",
|
190
|
-
)
|
191
|
-
|
192
|
-
def __init__(
|
193
|
-
self,
|
194
|
-
*,
|
195
|
-
phase: PhaseName,
|
196
|
-
suite_id: uuid.UUID,
|
197
|
-
scenario_id: uuid.UUID,
|
198
|
-
) -> None:
|
199
|
-
self.id = uuid.uuid4()
|
200
|
-
self.timestamp = time.time()
|
201
|
-
self.phase = phase
|
202
|
-
self.suite_id = suite_id
|
203
|
-
self.scenario_id = scenario_id
|
204
|
-
|
205
|
-
|
206
|
-
@dataclass
|
207
|
-
class TransitionId:
|
208
|
-
"""Id of the the that was hit."""
|
209
|
-
|
210
|
-
name: str
|
211
|
-
# Status code as defined in the transition, i.e. may be `default`
|
212
|
-
status_code: str
|
213
|
-
source: str
|
214
|
-
|
215
|
-
__slots__ = ("name", "status_code", "source")
|
216
|
-
|
217
|
-
|
218
|
-
@dataclass
|
219
|
-
class ResponseData:
|
220
|
-
"""Common data for responses."""
|
221
|
-
|
222
|
-
status_code: int
|
223
|
-
elapsed: float
|
224
|
-
__slots__ = ("status_code", "elapsed")
|
225
|
-
|
226
|
-
|
227
|
-
@dataclass
|
228
|
-
class StepFinished(StepEvent):
|
229
|
-
"""After executing a test case."""
|
230
|
-
|
231
|
-
status: Status | None
|
232
|
-
transition_id: TransitionId | None
|
233
|
-
target: str
|
234
|
-
response: Response | None
|
235
|
-
|
236
|
-
__slots__ = (
|
237
|
-
"id",
|
238
|
-
"timestamp",
|
239
|
-
"phase",
|
240
|
-
"status",
|
241
|
-
"suite_id",
|
242
|
-
"scenario_id",
|
243
|
-
"transition_id",
|
244
|
-
"target",
|
245
|
-
"response",
|
246
|
-
)
|
247
|
-
|
248
|
-
def __init__(
|
249
|
-
self,
|
250
|
-
*,
|
251
|
-
phase: PhaseName,
|
252
|
-
id: uuid.UUID,
|
253
|
-
suite_id: uuid.UUID,
|
254
|
-
scenario_id: uuid.UUID,
|
255
|
-
status: Status | None,
|
256
|
-
transition_id: TransitionId | None,
|
257
|
-
target: str,
|
258
|
-
response: Response | None,
|
259
|
-
) -> None:
|
260
|
-
self.id = id
|
261
|
-
self.timestamp = time.time()
|
262
|
-
self.phase = phase
|
263
|
-
self.status = status
|
264
|
-
self.suite_id = suite_id
|
265
|
-
self.scenario_id = scenario_id
|
266
|
-
self.transition_id = transition_id
|
267
|
-
self.target = target
|
268
|
-
self.response = response
|
269
|
-
|
270
|
-
|
271
177
|
@dataclass
|
272
178
|
class Interrupted(EngineEvent):
|
273
179
|
"""If execution was interrupted by Ctrl-C, or a received SIGTERM."""
|
@@ -27,6 +27,7 @@ def execute(engine: EngineContext, phase: Phase) -> events.EventGenerator:
|
|
27
27
|
thread = threading.Thread(
|
28
28
|
target=execute_state_machine_loop,
|
29
29
|
kwargs={"state_machine": state_machine, "event_queue": event_queue, "engine": engine},
|
30
|
+
name="schemathesis_stateful_tests",
|
30
31
|
)
|
31
32
|
status: Status | None = None
|
32
33
|
is_executed = False
|
@@ -5,6 +5,7 @@ import time
|
|
5
5
|
import unittest
|
6
6
|
from dataclasses import replace
|
7
7
|
from typing import Any
|
8
|
+
from warnings import catch_warnings
|
8
9
|
|
9
10
|
import hypothesis
|
10
11
|
from hypothesis.control import current_build_context
|
@@ -25,8 +26,8 @@ from schemathesis.generation.hypothesis.reporting import ignore_hypothesis_outpu
|
|
25
26
|
from schemathesis.generation.stateful.state_machine import (
|
26
27
|
DEFAULT_STATE_MACHINE_SETTINGS,
|
27
28
|
APIStateMachine,
|
28
|
-
|
29
|
-
|
29
|
+
StepInput,
|
30
|
+
StepOutput,
|
30
31
|
)
|
31
32
|
from schemathesis.generation.targets import TargetMetricCollector
|
32
33
|
|
@@ -83,7 +84,7 @@ def execute_state_machine_loop(
|
|
83
84
|
def get_call_kwargs(self, case: Case) -> dict[str, Any]:
|
84
85
|
return transport_kwargs
|
85
86
|
|
86
|
-
def _repr_step(self, rule: Rule, data: dict, result:
|
87
|
+
def _repr_step(self, rule: Rule, data: dict, result: StepOutput) -> str:
|
87
88
|
return ""
|
88
89
|
|
89
90
|
if config.override is not None:
|
@@ -96,38 +97,35 @@ def execute_state_machine_loop(
|
|
96
97
|
setattr(case, location, container)
|
97
98
|
return super().before_call(case)
|
98
99
|
|
99
|
-
def step(self,
|
100
|
+
def step(self, input: StepInput) -> StepOutput | None:
|
100
101
|
# Checking the stop event once inside `step` is sufficient as it is called frequently
|
101
102
|
# The idea is to stop the execution as soon as possible
|
102
|
-
if
|
103
|
-
|
104
|
-
|
103
|
+
if input.transition is not None:
|
104
|
+
self.recorder.record_case(
|
105
|
+
parent_id=input.transition.parent_id, transition=input.transition, case=input.case
|
106
|
+
)
|
105
107
|
else:
|
106
|
-
self.recorder.record_case(parent_id=None, case=case)
|
108
|
+
self.recorder.record_case(parent_id=None, transition=None, case=input.case)
|
107
109
|
if engine.has_to_stop:
|
108
110
|
raise KeyboardInterrupt
|
109
|
-
step_started = events.StepStarted(
|
110
|
-
phase=PhaseName.STATEFUL_TESTING, suite_id=suite_id, scenario_id=self._scenario_id
|
111
|
-
)
|
112
|
-
event_queue.put(step_started)
|
113
111
|
try:
|
114
112
|
if config.execution.unique_inputs:
|
115
|
-
cached = ctx.get_step_outcome(case)
|
113
|
+
cached = ctx.get_step_outcome(input.case)
|
116
114
|
if isinstance(cached, BaseException):
|
117
115
|
raise cached
|
118
116
|
elif cached is None:
|
119
117
|
return None
|
120
|
-
result = super().step(
|
118
|
+
result = super().step(input)
|
121
119
|
ctx.step_succeeded()
|
122
120
|
except FailureGroup as exc:
|
123
121
|
if config.execution.unique_inputs:
|
124
122
|
for failure in exc.exceptions:
|
125
|
-
ctx.store_step_outcome(case, failure)
|
123
|
+
ctx.store_step_outcome(input.case, failure)
|
126
124
|
ctx.step_failed()
|
127
125
|
raise
|
128
126
|
except Exception as exc:
|
129
127
|
if config.execution.unique_inputs:
|
130
|
-
ctx.store_step_outcome(case, exc)
|
128
|
+
ctx.store_step_outcome(input.case, exc)
|
131
129
|
ctx.step_errored()
|
132
130
|
raise
|
133
131
|
except KeyboardInterrupt:
|
@@ -135,34 +133,11 @@ def execute_state_machine_loop(
|
|
135
133
|
raise
|
136
134
|
except BaseException as exc:
|
137
135
|
if config.execution.unique_inputs:
|
138
|
-
ctx.store_step_outcome(case, exc)
|
136
|
+
ctx.store_step_outcome(input.case, exc)
|
139
137
|
raise exc
|
140
138
|
else:
|
141
139
|
if config.execution.unique_inputs:
|
142
|
-
ctx.store_step_outcome(case, None)
|
143
|
-
finally:
|
144
|
-
transition_id: events.TransitionId | None
|
145
|
-
if previous is not None:
|
146
|
-
transition = previous[1]
|
147
|
-
transition_id = events.TransitionId(
|
148
|
-
name=transition.name,
|
149
|
-
status_code=transition.status_code,
|
150
|
-
source=transition.operation.label,
|
151
|
-
)
|
152
|
-
else:
|
153
|
-
transition_id = None
|
154
|
-
event_queue.put(
|
155
|
-
events.StepFinished(
|
156
|
-
id=step_started.id,
|
157
|
-
suite_id=suite_id,
|
158
|
-
scenario_id=self._scenario_id,
|
159
|
-
phase=PhaseName.STATEFUL_TESTING,
|
160
|
-
status=ctx.current_step_status,
|
161
|
-
transition_id=transition_id,
|
162
|
-
target=case.operation.label,
|
163
|
-
response=ctx.current_response,
|
164
|
-
)
|
165
|
-
)
|
140
|
+
ctx.store_step_outcome(input.case, None)
|
166
141
|
return result
|
167
142
|
|
168
143
|
def validate_response(
|
@@ -189,7 +164,7 @@ def execute_state_machine_loop(
|
|
189
164
|
id=self._scenario_id,
|
190
165
|
suite_id=suite_id,
|
191
166
|
phase=PhaseName.STATEFUL_TESTING,
|
192
|
-
|
167
|
+
label=None,
|
193
168
|
status=ctx.current_scenario_status or Status.SKIP,
|
194
169
|
recorder=self.recorder,
|
195
170
|
elapsed_time=time.monotonic() - self._start_time,
|
@@ -223,7 +198,7 @@ def execute_state_machine_loop(
|
|
223
198
|
break
|
224
199
|
suite_status = Status.SUCCESS
|
225
200
|
try:
|
226
|
-
with ignore_hypothesis_output(): # type: ignore
|
201
|
+
with catch_warnings(), ignore_hypothesis_output(): # type: ignore
|
227
202
|
InstrumentedStateMachine.run(settings=config.execution.hypothesis_settings)
|
228
203
|
except KeyboardInterrupt:
|
229
204
|
# Raised in the state machine when the stop event is set or it is raised by the user's code
|
@@ -234,7 +209,7 @@ def execute_state_machine_loop(
|
|
234
209
|
event_queue.put(events.Interrupted(phase=PhaseName.STATEFUL_TESTING))
|
235
210
|
break
|
236
211
|
except unittest.case.SkipTest:
|
237
|
-
# If `explicit` phase is used and there are
|
212
|
+
# If `explicit` phase is used and there are no examples
|
238
213
|
suite_status = Status.SKIP
|
239
214
|
break
|
240
215
|
except FailureGroup as exc:
|
@@ -140,6 +140,7 @@ def worker_task(*, events_queue: Queue, producer: TaskProducer, ctx: EngineConte
|
|
140
140
|
id=scenario_started.id,
|
141
141
|
suite_id=suite_id,
|
142
142
|
phase=PhaseName.UNIT_TESTING,
|
143
|
+
label=label,
|
143
144
|
status=Status.ERROR,
|
144
145
|
recorder=ScenarioRecorder(label="Error"),
|
145
146
|
elapsed_time=0.0,
|
@@ -201,6 +201,7 @@ def run_test(
|
|
201
201
|
id=scenario_started.id,
|
202
202
|
suite_id=suite_id,
|
203
203
|
phase=PhaseName.UNIT_TESTING,
|
204
|
+
label=operation.label,
|
204
205
|
recorder=recorder,
|
205
206
|
status=status,
|
206
207
|
elapsed_time=test_elapsed_time,
|
@@ -263,7 +264,7 @@ def cached_test_func(f: Callable) -> Callable:
|
|
263
264
|
|
264
265
|
@cached_test_func
|
265
266
|
def test_func(*, ctx: EngineContext, case: Case, recorder: ScenarioRecorder) -> None:
|
266
|
-
recorder.record_case(parent_id=None, case=case)
|
267
|
+
recorder.record_case(parent_id=None, transition=None, case=case)
|
267
268
|
try:
|
268
269
|
response = case.call(**ctx.transport_kwargs)
|
269
270
|
except (requests.Timeout, requests.ConnectionError) as error:
|
schemathesis/engine/recorder.py
CHANGED
@@ -14,6 +14,8 @@ from schemathesis.generation.case import Case
|
|
14
14
|
if TYPE_CHECKING:
|
15
15
|
import requests
|
16
16
|
|
17
|
+
from schemathesis.generation.stateful.state_machine import Transition
|
18
|
+
|
17
19
|
|
18
20
|
@dataclass
|
19
21
|
class ScenarioRecorder:
|
@@ -42,9 +44,9 @@ class ScenarioRecorder:
|
|
42
44
|
self.checks = {}
|
43
45
|
self.interactions = {}
|
44
46
|
|
45
|
-
def record_case(self, *, parent_id: str | None, case: Case) -> None:
|
47
|
+
def record_case(self, *, parent_id: str | None, transition: Transition | None, case: Case) -> None:
|
46
48
|
"""Record a test case and its relationship to a parent, if applicable."""
|
47
|
-
self.cases[case.id] = CaseNode(value=case, parent_id=parent_id)
|
49
|
+
self.cases[case.id] = CaseNode(value=case, parent_id=parent_id, transition=transition)
|
48
50
|
|
49
51
|
def record_response(self, *, case_id: str, response: Response) -> None:
|
50
52
|
"""Record the API response for a given test case."""
|
@@ -133,8 +135,11 @@ class CaseNode:
|
|
133
135
|
|
134
136
|
value: Case
|
135
137
|
parent_id: str | None
|
138
|
+
# Transition may be absent if `parent_id` is present for cases when a case is derived inside a check
|
139
|
+
# and outside of the implemented transition logic (e.g. Open API links)
|
140
|
+
transition: Transition | None
|
136
141
|
|
137
|
-
__slots__ = ("value", "parent_id")
|
142
|
+
__slots__ = ("value", "parent_id", "transition")
|
138
143
|
|
139
144
|
|
140
145
|
@dataclass
|