swaggerforge 0.1.0__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.
- swaggerforge/__init__.py +0 -0
- swaggerforge/__main__.py +0 -0
- swaggerforge/cli.py +67 -0
- swaggerforge/generator.py +441 -0
- swaggerforge/models.py +46 -0
- swaggerforge/output.py +53 -0
- swaggerforge/parser.py +108 -0
- swaggerforge/template.py +116 -0
- swaggerforge/templates/test_file.py.j2 +24 -0
- swaggerforge/validator.py +44 -0
- swaggerforge-0.1.0.dist-info/METADATA +99 -0
- swaggerforge-0.1.0.dist-info/RECORD +16 -0
- swaggerforge-0.1.0.dist-info/WHEEL +5 -0
- swaggerforge-0.1.0.dist-info/entry_points.txt +2 -0
- swaggerforge-0.1.0.dist-info/licenses/LICENSE +21 -0
- swaggerforge-0.1.0.dist-info/top_level.txt +1 -0
swaggerforge/__init__.py
ADDED
|
File without changes
|
swaggerforge/__main__.py
ADDED
|
File without changes
|
swaggerforge/cli.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Command-line interface for SwaggerForge."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from swaggerforge.validator import load_and_validate, SpecValidationError
|
|
5
|
+
from swaggerforge.parser import parse_spec, SpecParseError
|
|
6
|
+
from swaggerforge.generator import generate_scenarios
|
|
7
|
+
from swaggerforge.output import write_test_files
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group()
|
|
11
|
+
@click.version_option()
|
|
12
|
+
def main():
|
|
13
|
+
"""SwaggerForge generate pytest tests from OpenAPI specifications."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@main.command()
|
|
18
|
+
@click.option(
|
|
19
|
+
"--spec",
|
|
20
|
+
required=True,
|
|
21
|
+
type=click.Path(exists=True),
|
|
22
|
+
help="Path to the OpenAPI specification file (JSON or YAML).",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--url",
|
|
26
|
+
required=True,
|
|
27
|
+
help="Base URL of the API to be tested.",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--output",
|
|
31
|
+
default="tests_generated",
|
|
32
|
+
type=click.Path(),
|
|
33
|
+
help="Directory where generated test files will be written.",
|
|
34
|
+
)
|
|
35
|
+
def generate(spec, url, output):
|
|
36
|
+
"""Generate pytest test files from an OpenAPI specification."""
|
|
37
|
+
click.echo(f"Reading specification: {spec}")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
spec_dict = load_and_validate(spec)
|
|
41
|
+
except SpecValidationError as error:
|
|
42
|
+
raise click.ClickException(str(error))
|
|
43
|
+
|
|
44
|
+
click.echo("Specification is valid.")
|
|
45
|
+
click.echo(f"API title: {spec_dict['info']['title']}")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
endpoints = parse_spec(spec)
|
|
49
|
+
except SpecParseError as error:
|
|
50
|
+
raise click.ClickException(str(error))
|
|
51
|
+
|
|
52
|
+
tags = sorted({endpoint.tag for endpoint in endpoints})
|
|
53
|
+
click.echo(f"Found {len(endpoints)} endpoints across {len(tags)} resources: {', '.join(tags)}")
|
|
54
|
+
|
|
55
|
+
scenarios = generate_scenarios(endpoints)
|
|
56
|
+
click.echo(f"Generated {len(scenarios)} test scenarios.")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
written = write_test_files(scenarios, url, output)
|
|
60
|
+
except OSError as error:
|
|
61
|
+
raise click.ClickException(
|
|
62
|
+
f"Could not write test files to '{output}': {error}"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
click.echo(f"Wrote {len(written)} test file(s) to '{output}':")
|
|
66
|
+
for path in written:
|
|
67
|
+
click.echo(f" - {path}")
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Generation of test scenarios from parsed OpenAPI endpoints.
|
|
2
|
+
|
|
3
|
+
This module turns the internal endpoint model into concrete test scenarios.
|
|
4
|
+
Its first building block is a deterministic example-value generator that
|
|
5
|
+
produces valid sample values from a JSON schema.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from swaggerforge.models import TestScenario
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def example_value(schema):
|
|
12
|
+
"""Produce a deterministic example value conforming to a JSON schema.
|
|
13
|
+
|
|
14
|
+
The same schema always yields the same value, which is required for
|
|
15
|
+
reproducible test generation (WNF11).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
schema: A resolved JSON schema fragment (a dictionary).
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A Python value (str, int, float, bool, list, or dict) valid under
|
|
22
|
+
the schema.
|
|
23
|
+
"""
|
|
24
|
+
if not isinstance(schema, dict):
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
enum = schema.get("enum")
|
|
28
|
+
if enum:
|
|
29
|
+
return enum[0]
|
|
30
|
+
|
|
31
|
+
schema_type = schema.get("type")
|
|
32
|
+
|
|
33
|
+
if schema_type == "string":
|
|
34
|
+
return _example_string(schema)
|
|
35
|
+
if schema_type == "integer":
|
|
36
|
+
return _example_integer(schema)
|
|
37
|
+
if schema_type == "number":
|
|
38
|
+
return _example_integer(schema)
|
|
39
|
+
if schema_type == "boolean":
|
|
40
|
+
return True
|
|
41
|
+
if schema_type == "array":
|
|
42
|
+
item_schema = schema.get("items", {})
|
|
43
|
+
return [example_value(item_schema)]
|
|
44
|
+
if schema_type == "object":
|
|
45
|
+
return _example_object(schema)
|
|
46
|
+
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _example_integer(schema):
|
|
51
|
+
"""Return a valid integer, respecting 'minimum' if present."""
|
|
52
|
+
minimum = schema.get("minimum")
|
|
53
|
+
if minimum is not None:
|
|
54
|
+
return minimum
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# deterministic values for common string formats
|
|
59
|
+
_STRING_FORMATS = {
|
|
60
|
+
"date-time": "2024-01-01T00:00:00Z",
|
|
61
|
+
"date": "2024-01-01",
|
|
62
|
+
"email": "user@example.com",
|
|
63
|
+
"uuid": "00000000-0000-0000-0000-000000000000",
|
|
64
|
+
"uri": "https://example.com",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _example_string(schema):
|
|
69
|
+
"""Return a string value, honoring a recognized 'format' if present."""
|
|
70
|
+
string_format = schema.get("format")
|
|
71
|
+
return _STRING_FORMATS.get(string_format, "string")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _example_object(schema):
|
|
75
|
+
"""Build a dict with an example value for each declared property."""
|
|
76
|
+
result = {}
|
|
77
|
+
properties = schema.get("properties", {})
|
|
78
|
+
for name, prop_schema in properties.items():
|
|
79
|
+
result[name] = example_value(prop_schema)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def generate_positive_scenario(endpoint):
|
|
84
|
+
"""Build a positive (happy-path) test scenario for an endpoint.
|
|
85
|
+
|
|
86
|
+
Sends a valid request conforming to the schema and expects a 2xx
|
|
87
|
+
response code, following the equivalence-partitioning technique for
|
|
88
|
+
the valid input class.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
endpoint: The Endpoint to generate a scenario for.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A single-element list containing the positive-case TestScenario.
|
|
95
|
+
"""
|
|
96
|
+
request_body = None
|
|
97
|
+
if endpoint.request_body is not None:
|
|
98
|
+
request_body = example_value(endpoint.request_body)
|
|
99
|
+
|
|
100
|
+
return [TestScenario(
|
|
101
|
+
endpoint=endpoint,
|
|
102
|
+
name="success",
|
|
103
|
+
scenario_type="positive",
|
|
104
|
+
description=f"{endpoint.method.upper()} {endpoint.path} - positive scenario",
|
|
105
|
+
path_params=_example_path_params(endpoint),
|
|
106
|
+
query_params=_example_query_params(endpoint),
|
|
107
|
+
request_body=request_body,
|
|
108
|
+
expected_status=_success_codes(endpoint),
|
|
109
|
+
)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _success_codes(endpoint):
|
|
113
|
+
"""Return the 2xx response codes declared for an endpoint."""
|
|
114
|
+
return [code for code in endpoint.responses if code.startswith("2")]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _example_path_params(endpoint):
|
|
118
|
+
"""Build example values for an endpoint's path parameters."""
|
|
119
|
+
params = {}
|
|
120
|
+
for param in endpoint.parameters:
|
|
121
|
+
if param.location == "path":
|
|
122
|
+
params[param.name] = example_value(param.schema)
|
|
123
|
+
return params
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _example_query_params(endpoint):
|
|
127
|
+
"""Build example values for an endpoint's required query parameters."""
|
|
128
|
+
params = {}
|
|
129
|
+
for param in endpoint.parameters:
|
|
130
|
+
if param.location == "query" and param.required:
|
|
131
|
+
params[param.name] = example_value(param.schema)
|
|
132
|
+
return params
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def generate_missing_field_scenario(endpoint):
|
|
136
|
+
"""Build a negative scenario that omits a required field from the body.
|
|
137
|
+
|
|
138
|
+
Takes a valid request body and removes the first required field,
|
|
139
|
+
following the equivalence-partitioning technique for the invalid
|
|
140
|
+
input class.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
endpoint: The Endpoint to generate a scenario for.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A single-element list with the missing-field TestScenario, or an
|
|
147
|
+
empty list if the endpoint has no request body with required fields.
|
|
148
|
+
"""
|
|
149
|
+
if endpoint.request_body is None:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
required_fields = endpoint.request_body.get("required", [])
|
|
153
|
+
if not required_fields:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
field_to_remove = required_fields[0]
|
|
157
|
+
|
|
158
|
+
body = example_value(endpoint.request_body)
|
|
159
|
+
body.pop(field_to_remove, None)
|
|
160
|
+
|
|
161
|
+
return [TestScenario(
|
|
162
|
+
endpoint=endpoint,
|
|
163
|
+
name=f"missing_{field_to_remove}",
|
|
164
|
+
scenario_type="negative",
|
|
165
|
+
description=(
|
|
166
|
+
f"{endpoint.method.upper()} {endpoint.path} - "
|
|
167
|
+
f"missing required field '{field_to_remove}' (expects 400)"
|
|
168
|
+
),
|
|
169
|
+
path_params=_example_path_params(endpoint),
|
|
170
|
+
query_params=_example_query_params(endpoint),
|
|
171
|
+
request_body=body,
|
|
172
|
+
expected_status=["400"],
|
|
173
|
+
)]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# A deterministic, unlikely-to-exist identifier used for "resource not found" tests.
|
|
177
|
+
NONEXISTENT_INT_ID = 999999999
|
|
178
|
+
NONEXISTENT_STR_ID = "nonexistent-id-000000"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def generate_nonexistent_scenario(endpoint):
|
|
182
|
+
"""Build a negative scenario requesting a resource that does not exist.
|
|
183
|
+
|
|
184
|
+
Applies to endpoints with a path parameter (a resource identifier).
|
|
185
|
+
Uses a deterministic, unlikely identifier and expects a 404 response.
|
|
186
|
+
This is a best-effort heuristic: the spec describes the API's shape,
|
|
187
|
+
not its data, so the absence of the resource cannot be guaranteed.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
endpoint: The Endpoint to generate a scenario for.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
A single-element list with the not-found TestScenario, or an empty
|
|
194
|
+
list if the endpoint has no path parameter.
|
|
195
|
+
"""
|
|
196
|
+
path_params = {}
|
|
197
|
+
has_path_param = False
|
|
198
|
+
for param in endpoint.parameters:
|
|
199
|
+
if param.location == "path":
|
|
200
|
+
has_path_param = True
|
|
201
|
+
path_params[param.name] = _nonexistent_value(param.schema)
|
|
202
|
+
|
|
203
|
+
if not has_path_param:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
expected = "404"
|
|
207
|
+
|
|
208
|
+
return [TestScenario(
|
|
209
|
+
endpoint=endpoint,
|
|
210
|
+
name="nonexistent",
|
|
211
|
+
scenario_type="negative",
|
|
212
|
+
description=(
|
|
213
|
+
f"{endpoint.method.upper()} {endpoint.path} - "
|
|
214
|
+
f"nonexistent resource (expects 404)"
|
|
215
|
+
),
|
|
216
|
+
path_params=path_params,
|
|
217
|
+
query_params=_example_query_params(endpoint),
|
|
218
|
+
request_body=None,
|
|
219
|
+
expected_status=[expected],
|
|
220
|
+
)]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def generate_wrong_type_scenario(endpoint):
|
|
224
|
+
"""Build a negative scenario sending a wrong-typed field in the body.
|
|
225
|
+
|
|
226
|
+
Takes a valid request body and replaces the first numeric field with a
|
|
227
|
+
string value, violating the declared type. Expects a 400 or 422
|
|
228
|
+
response.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
endpoint: The Endpoint to generate a scenario for.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
A single-element list with the wrong-type TestScenario, or an empty
|
|
235
|
+
list if the body has no numeric field to corrupt.
|
|
236
|
+
"""
|
|
237
|
+
if endpoint.request_body is None:
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
properties = endpoint.request_body.get("properties", {})
|
|
241
|
+
field_to_corrupt = _first_numeric_field(properties)
|
|
242
|
+
if field_to_corrupt is None:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
body = example_value(endpoint.request_body)
|
|
246
|
+
body[field_to_corrupt] = "not_a_number"
|
|
247
|
+
|
|
248
|
+
return [TestScenario(
|
|
249
|
+
endpoint=endpoint,
|
|
250
|
+
name=f"wrong_type_{field_to_corrupt}",
|
|
251
|
+
scenario_type="negative",
|
|
252
|
+
description=(
|
|
253
|
+
f"{endpoint.method.upper()} {endpoint.path} - "
|
|
254
|
+
f"wrong type for field '{field_to_corrupt}' (expects 400 or 422)"
|
|
255
|
+
),
|
|
256
|
+
path_params=_example_path_params(endpoint),
|
|
257
|
+
query_params=_example_query_params(endpoint),
|
|
258
|
+
request_body=body,
|
|
259
|
+
expected_status=["400", "422"],
|
|
260
|
+
)]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def generate_boundary_scenarios(endpoint):
|
|
264
|
+
"""Build boundary-value scenarios for an endpoint's constrained fields.
|
|
265
|
+
|
|
266
|
+
Applies Boundary Value Analysis: for each body field that declares
|
|
267
|
+
numeric (minimum/maximum) or string-length (minLength/maxLength)
|
|
268
|
+
constraints, generates scenarios at and just beyond each edge. Valid
|
|
269
|
+
edges expect a 2xx response; out-of-range values expect 400 or 422.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
endpoint: The Endpoint to generate scenarios for.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
A list of TestScenario objects (empty if no constrained fields).
|
|
276
|
+
"""
|
|
277
|
+
if endpoint.request_body is None:
|
|
278
|
+
return []
|
|
279
|
+
|
|
280
|
+
scenarios = []
|
|
281
|
+
properties = endpoint.request_body.get("properties", {})
|
|
282
|
+
for field_name, field_schema in properties.items():
|
|
283
|
+
if not isinstance(field_schema, dict):
|
|
284
|
+
continue
|
|
285
|
+
for value, is_valid, label in _boundary_cases(field_schema):
|
|
286
|
+
scenarios.append(
|
|
287
|
+
_make_boundary_scenario(endpoint, field_name, value, is_valid, label)
|
|
288
|
+
)
|
|
289
|
+
return scenarios
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def generate_boolean_scenarios(endpoint):
|
|
293
|
+
"""Build scenarios exercising both values of each boolean body field.
|
|
294
|
+
|
|
295
|
+
For every boolean field in the request body, generates two scenarios -
|
|
296
|
+
one sending True and one sending False - since both are valid values an
|
|
297
|
+
API should accept. This gives coverage of logical parameters (WF12).
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
endpoint: The Endpoint to generate scenarios for.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
A list of TestScenario objects (empty if no boolean fields).
|
|
304
|
+
"""
|
|
305
|
+
if endpoint.request_body is None:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
scenarios = []
|
|
309
|
+
properties = endpoint.request_body.get("properties", {})
|
|
310
|
+
for field_name, field_schema in properties.items():
|
|
311
|
+
if not isinstance(field_schema, dict):
|
|
312
|
+
continue
|
|
313
|
+
if field_schema.get("type") != "boolean":
|
|
314
|
+
continue
|
|
315
|
+
for value in (True, False):
|
|
316
|
+
scenarios.append(
|
|
317
|
+
_make_boolean_scenario(endpoint, field_name, value)
|
|
318
|
+
)
|
|
319
|
+
return scenarios
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _make_boolean_scenario(endpoint, field_name, value):
|
|
323
|
+
"""Construct one boolean TestScenario for a field set to True or False."""
|
|
324
|
+
body = example_value(endpoint.request_body)
|
|
325
|
+
body[field_name] = value
|
|
326
|
+
|
|
327
|
+
return TestScenario(
|
|
328
|
+
endpoint=endpoint,
|
|
329
|
+
name=f"boolean_{field_name}_{str(value).lower()}",
|
|
330
|
+
scenario_type="boolean",
|
|
331
|
+
description=(
|
|
332
|
+
f"{endpoint.method.upper()} {endpoint.path} - "
|
|
333
|
+
f"boolean field '{field_name}' = {str(value).lower()}"
|
|
334
|
+
),
|
|
335
|
+
path_params=_example_path_params(endpoint),
|
|
336
|
+
query_params=_example_query_params(endpoint),
|
|
337
|
+
request_body=body,
|
|
338
|
+
expected_status=_success_codes(endpoint) or ["200"],
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _boundary_cases(field_schema):
|
|
343
|
+
"""Return (value, is_valid, label) tuples for a field's declared boundaries."""
|
|
344
|
+
field_type = field_schema.get("type")
|
|
345
|
+
if field_type in ("integer", "number"):
|
|
346
|
+
return _numeric_boundary_cases(field_schema)
|
|
347
|
+
if field_type == "string":
|
|
348
|
+
return _string_boundary_cases(field_schema)
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _numeric_boundary_cases(field_schema):
|
|
353
|
+
"""Boundary cases for a numeric field with minimum/maximum."""
|
|
354
|
+
cases = []
|
|
355
|
+
minimum = field_schema.get("minimum")
|
|
356
|
+
maximum = field_schema.get("maximum")
|
|
357
|
+
if minimum is not None:
|
|
358
|
+
cases.append((minimum - 1, False, "below_min"))
|
|
359
|
+
cases.append((minimum, True, "at_min"))
|
|
360
|
+
if maximum is not None:
|
|
361
|
+
cases.append((maximum, True, "at_max"))
|
|
362
|
+
cases.append((maximum + 1, False, "above_max"))
|
|
363
|
+
return cases
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _string_boundary_cases(field_schema):
|
|
367
|
+
"""Boundary cases for a string field with minLength/maxLength."""
|
|
368
|
+
cases = []
|
|
369
|
+
min_length = field_schema.get("minLength")
|
|
370
|
+
max_length = field_schema.get("maxLength")
|
|
371
|
+
if min_length is not None and min_length > 0:
|
|
372
|
+
cases.append(("x" * (min_length - 1), False, "below_min_length"))
|
|
373
|
+
cases.append(("x" * min_length, True, "at_min_length"))
|
|
374
|
+
if max_length is not None:
|
|
375
|
+
cases.append(("x" * max_length, True, "at_max_length"))
|
|
376
|
+
cases.append(("x" * (max_length + 1), False, "above_max_length"))
|
|
377
|
+
return cases
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _make_boundary_scenario(endpoint, field_name, value, is_valid, label):
|
|
381
|
+
"""Construct one boundary TestScenario for a field set to a given value."""
|
|
382
|
+
body = example_value(endpoint.request_body)
|
|
383
|
+
body[field_name] = value
|
|
384
|
+
|
|
385
|
+
if is_valid:
|
|
386
|
+
expected = _success_codes(endpoint) or ["200"]
|
|
387
|
+
else:
|
|
388
|
+
expected = ["400", "422"]
|
|
389
|
+
|
|
390
|
+
return TestScenario(
|
|
391
|
+
endpoint=endpoint,
|
|
392
|
+
name=f"boundary_{field_name}_{label}",
|
|
393
|
+
scenario_type="boundary",
|
|
394
|
+
description=(
|
|
395
|
+
f"{endpoint.method.upper()} {endpoint.path} - "
|
|
396
|
+
f"boundary value for '{field_name}' ({label})"
|
|
397
|
+
),
|
|
398
|
+
path_params=_example_path_params(endpoint),
|
|
399
|
+
query_params=_example_query_params(endpoint),
|
|
400
|
+
request_body=body,
|
|
401
|
+
expected_status=expected,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _first_numeric_field(properties):
|
|
406
|
+
"""Return the name of the first integer/number property, or None."""
|
|
407
|
+
for name, prop_schema in properties.items():
|
|
408
|
+
if isinstance(prop_schema, dict) and prop_schema.get("type") in ("integer", "number"):
|
|
409
|
+
return name
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _nonexistent_value(schema):
|
|
414
|
+
"""Return a deterministic, unlikely identifier matching the schema type."""
|
|
415
|
+
if schema.get("type") == "integer":
|
|
416
|
+
return NONEXISTENT_INT_ID
|
|
417
|
+
return NONEXISTENT_STR_ID
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def generate_scenarios(endpoints):
|
|
421
|
+
"""Generate all applicable test scenarios for a list of endpoints.
|
|
422
|
+
|
|
423
|
+
Runs every scenario generator over each endpoint. Every generator
|
|
424
|
+
returns a (possibly empty) list of scenarios, so the results are simply
|
|
425
|
+
flattened into one list.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
endpoints: A list of Endpoint objects.
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
A list of TestScenario objects.
|
|
432
|
+
"""
|
|
433
|
+
scenarios = []
|
|
434
|
+
for endpoint in endpoints:
|
|
435
|
+
scenarios.extend(generate_positive_scenario(endpoint))
|
|
436
|
+
scenarios.extend(generate_missing_field_scenario(endpoint))
|
|
437
|
+
scenarios.extend(generate_nonexistent_scenario(endpoint))
|
|
438
|
+
scenarios.extend(generate_wrong_type_scenario(endpoint))
|
|
439
|
+
scenarios.extend(generate_boundary_scenarios(endpoint))
|
|
440
|
+
scenarios.extend(generate_boolean_scenarios(endpoint))
|
|
441
|
+
return scenarios
|
swaggerforge/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Internal data model for SwaggerForge.
|
|
2
|
+
|
|
3
|
+
These dataclasses represent the structured form of an OpenAPI specification
|
|
4
|
+
used internally by the library. The Parser produces them from a raw
|
|
5
|
+
specification; the Generator and Template modules consume them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Parameter:
|
|
13
|
+
"""A single operation parameter (path, query, or header)."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
location: str
|
|
17
|
+
required: bool
|
|
18
|
+
schema: dict = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Endpoint:
|
|
23
|
+
"""A single API operation: one HTTP method on one path."""
|
|
24
|
+
|
|
25
|
+
path: str
|
|
26
|
+
method: str
|
|
27
|
+
tag: str
|
|
28
|
+
operation_id: str
|
|
29
|
+
parameters: list[Parameter] = field(default_factory=list)
|
|
30
|
+
request_body: dict | None = None
|
|
31
|
+
responses: list[str] = field(default_factory=list)
|
|
32
|
+
response_schema: dict | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class TestScenario:
|
|
37
|
+
"""A single generated test case for one endpoint."""
|
|
38
|
+
|
|
39
|
+
endpoint: Endpoint
|
|
40
|
+
name: str
|
|
41
|
+
scenario_type: str
|
|
42
|
+
description: str
|
|
43
|
+
path_params: dict = field(default_factory=dict)
|
|
44
|
+
query_params: dict = field(default_factory=dict)
|
|
45
|
+
request_body: dict | None = None
|
|
46
|
+
expected_status: list[str] = field(default_factory=list)
|
swaggerforge/output.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Writing generated test files to disk.
|
|
2
|
+
|
|
3
|
+
This module groups scenarios by resource tag, renders each group into a
|
|
4
|
+
pytest file, and writes the files into the output directory. It only ever
|
|
5
|
+
writes into the output directory and never modifies the input
|
|
6
|
+
specification (WNF08).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from swaggerforge.template import render_test_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def write_test_files(scenarios, base_url, output_dir):
|
|
16
|
+
"""Group scenarios by tag, render them, and write one file per tag.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
scenarios: A list of TestScenario objects.
|
|
20
|
+
base_url: The base URL of the API under test.
|
|
21
|
+
output_dir: Directory where the test files will be written.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A sorted list of paths to the files that were written.
|
|
25
|
+
"""
|
|
26
|
+
grouped = _group_by_tag(scenarios)
|
|
27
|
+
|
|
28
|
+
output_path = Path(output_dir)
|
|
29
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
written = []
|
|
32
|
+
for tag in sorted(grouped):
|
|
33
|
+
filename = f"test_{_sanitize(tag)}.py"
|
|
34
|
+
file_path = output_path / filename
|
|
35
|
+
content = render_test_file(filename, base_url, grouped[tag])
|
|
36
|
+
file_path.write_text(content, encoding="utf-8")
|
|
37
|
+
written.append(str(file_path))
|
|
38
|
+
|
|
39
|
+
return written
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _group_by_tag(scenarios):
|
|
43
|
+
"""Group scenarios into a dict keyed by their endpoint's tag."""
|
|
44
|
+
grouped = {}
|
|
45
|
+
for scenario in scenarios:
|
|
46
|
+
tag = scenario.endpoint.tag
|
|
47
|
+
grouped.setdefault(tag, []).append(scenario)
|
|
48
|
+
return grouped
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _sanitize(tag):
|
|
52
|
+
"""Replace characters not valid in a filename with underscores."""
|
|
53
|
+
return re.sub(r"[^a-zA-Z0-9_]", "_", tag)
|
swaggerforge/parser.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Parsing of OpenAPI specifications into the internal data model.
|
|
2
|
+
|
|
3
|
+
This module reads an OpenAPI specification, resolves any $ref references,
|
|
4
|
+
and extracts a structured list of endpoints grouped by resource tag.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from prance import ResolvingParser
|
|
8
|
+
|
|
9
|
+
from swaggerforge.models import Endpoint, Parameter
|
|
10
|
+
|
|
11
|
+
# HTTP methods that represent operations we generate tests for.
|
|
12
|
+
HTTP_METHODS = ("get", "post", "put", "patch", "delete")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SpecParseError(Exception):
|
|
16
|
+
"""Raised when the specification cannot be parsed into the data model."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_spec(spec_path):
|
|
21
|
+
"""Parse an OpenAPI specification into a list of endpoints.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
spec_path: Path to the OpenAPI specification file (JSON or YAML).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A list of Endpoint objects extracted from the specification.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
SpecParseError: If the specification cannot be parsed.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
parser = ResolvingParser(spec_path, backend="openapi-spec-validator")
|
|
34
|
+
spec = parser.specification
|
|
35
|
+
except Exception as error:
|
|
36
|
+
raise SpecParseError(
|
|
37
|
+
f"Could not parse specification '{spec_path}': {error}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
endpoints = []
|
|
41
|
+
|
|
42
|
+
paths = spec.get("paths", {})
|
|
43
|
+
for path, path_item in paths.items():
|
|
44
|
+
for method in HTTP_METHODS:
|
|
45
|
+
operation = path_item.get(method)
|
|
46
|
+
if operation is None:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
endpoint = Endpoint(
|
|
50
|
+
path=path,
|
|
51
|
+
method=method,
|
|
52
|
+
tag=_extract_tag(operation),
|
|
53
|
+
operation_id=operation.get("operationId", ""),
|
|
54
|
+
parameters=_extract_parameters(operation),
|
|
55
|
+
request_body=_extract_request_body(operation),
|
|
56
|
+
responses=list(operation.get("responses", {}).keys()),
|
|
57
|
+
response_schema=_extract_response_schema(operation),
|
|
58
|
+
)
|
|
59
|
+
endpoints.append(endpoint)
|
|
60
|
+
|
|
61
|
+
return endpoints
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _extract_tag(operation):
|
|
65
|
+
"""Return the first tag of an operation, or 'default' if none."""
|
|
66
|
+
tags = operation.get("tags", [])
|
|
67
|
+
if tags:
|
|
68
|
+
return tags[0]
|
|
69
|
+
return "default"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_parameters(operation):
|
|
73
|
+
"""Build a list of Parameter objects from an operation."""
|
|
74
|
+
parameters = []
|
|
75
|
+
for param in operation.get("parameters", []):
|
|
76
|
+
parameters.append(
|
|
77
|
+
Parameter(
|
|
78
|
+
name=param.get("name", ""),
|
|
79
|
+
location=param.get("in", ""),
|
|
80
|
+
required=param.get("required", False),
|
|
81
|
+
schema=param.get("schema", {}),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return parameters
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _extract_request_body(operation):
|
|
88
|
+
"""Return the resolved JSON request body schema, or None."""
|
|
89
|
+
request_body = operation.get("requestBody")
|
|
90
|
+
if request_body is None:
|
|
91
|
+
return None
|
|
92
|
+
content = request_body.get("content", {})
|
|
93
|
+
json_content = content.get("application/json")
|
|
94
|
+
if json_content is None:
|
|
95
|
+
return None
|
|
96
|
+
return json_content.get("schema")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_response_schema(operation):
|
|
100
|
+
"""Return the resolved JSON schema of the success (2xx) response, or None."""
|
|
101
|
+
responses = operation.get("responses", {})
|
|
102
|
+
for code, response in responses.items():
|
|
103
|
+
if code.startswith("2"):
|
|
104
|
+
content = response.get("content", {})
|
|
105
|
+
json_content = content.get("application/json")
|
|
106
|
+
if json_content is not None:
|
|
107
|
+
return json_content.get("schema")
|
|
108
|
+
return None
|
swaggerforge/template.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Rendering of test scenarios into pytest source code.
|
|
2
|
+
|
|
3
|
+
This module turns TestScenario objects into the text of a pytest test
|
|
4
|
+
file, using a Jinja2 template. All computation (building function names,
|
|
5
|
+
request calls, and literals) happens here; the template only places the
|
|
6
|
+
pre-computed values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from jinja2 import Environment, PackageLoader
|
|
10
|
+
|
|
11
|
+
_env = Environment(
|
|
12
|
+
loader=PackageLoader("swaggerforge"),
|
|
13
|
+
trim_blocks=True,
|
|
14
|
+
lstrip_blocks=True,
|
|
15
|
+
keep_trailing_newline=True,
|
|
16
|
+
autoescape=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_test_file(filename, base_url, scenarios):
|
|
21
|
+
"""Render a list of scenarios into the text of a pytest test file.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
filename: The name of the file being generated (e.g. 'test_pet.py').
|
|
25
|
+
base_url: The base URL of the API under test.
|
|
26
|
+
scenarios: A list of TestScenario objects for one resource tag.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The rendered file content as a string.
|
|
30
|
+
"""
|
|
31
|
+
template = _env.get_template("test_file.py.j2")
|
|
32
|
+
rendered_scenarios = [_prepare_scenario(s) for s in scenarios]
|
|
33
|
+
return template.render(
|
|
34
|
+
filename=filename,
|
|
35
|
+
base_url=base_url,
|
|
36
|
+
scenarios=rendered_scenarios,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare_scenario(scenario):
|
|
41
|
+
"""Pre-compute all values the template needs for one scenario."""
|
|
42
|
+
return {
|
|
43
|
+
"function_name": build_function_name(scenario),
|
|
44
|
+
"docstring": scenario.description,
|
|
45
|
+
"request_call": build_request_call(scenario),
|
|
46
|
+
"expected_status": _status_codes_literal(scenario),
|
|
47
|
+
"response_schema": _response_schema_literal(scenario),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_function_name(scenario):
|
|
52
|
+
"""Build a unique pytest function name for a scenario.
|
|
53
|
+
|
|
54
|
+
Uses the operationId (unique per operation in OpenAPI) when available,
|
|
55
|
+
falling back to a method-and-path-derived identifier otherwise.
|
|
56
|
+
"""
|
|
57
|
+
endpoint = scenario.endpoint
|
|
58
|
+
if endpoint.operation_id:
|
|
59
|
+
base = _to_snake_case(endpoint.operation_id)
|
|
60
|
+
else:
|
|
61
|
+
base = _path_to_identifier(endpoint.method, endpoint.path)
|
|
62
|
+
return f"test_{base}_{scenario.name}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _to_snake_case(operation_id):
|
|
66
|
+
"""Convert a camelCase operationId to snake_case (e.g. getPetById -> get_pet_by_id)."""
|
|
67
|
+
result = []
|
|
68
|
+
for char in operation_id:
|
|
69
|
+
if char.isupper():
|
|
70
|
+
result.append("_")
|
|
71
|
+
result.append(char.lower())
|
|
72
|
+
else:
|
|
73
|
+
result.append(char)
|
|
74
|
+
return "".join(result).strip("_")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _path_to_identifier(method, path):
|
|
78
|
+
"""Build an identifier from method and path when no operationId exists."""
|
|
79
|
+
cleaned = path.replace("/", "_").replace("{", "").replace("}", "")
|
|
80
|
+
cleaned = cleaned.strip("_")
|
|
81
|
+
return f"{method}_{cleaned}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_request_call(scenario):
|
|
85
|
+
"""Assemble the full requests.<method>(...) call as a string."""
|
|
86
|
+
endpoint = scenario.endpoint
|
|
87
|
+
url = _build_url(endpoint.path, scenario.path_params)
|
|
88
|
+
|
|
89
|
+
parts = [f'f"{{base_url}}{url}"']
|
|
90
|
+
if scenario.query_params:
|
|
91
|
+
parts.append(f"params={scenario.query_params!r}")
|
|
92
|
+
if scenario.request_body is not None:
|
|
93
|
+
parts.append(f"json={scenario.request_body!r}")
|
|
94
|
+
|
|
95
|
+
args = ", ".join(parts)
|
|
96
|
+
return f"requests.{endpoint.method}({args})"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_url(path, path_params):
|
|
100
|
+
"""Substitute path parameter values into a path template."""
|
|
101
|
+
result = path
|
|
102
|
+
for name, value in path_params.items():
|
|
103
|
+
result = result.replace("{" + name + "}", str(value))
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _status_codes_literal(scenario):
|
|
108
|
+
"""Return the expected status codes as a list of ints, e.g. [200, 201]."""
|
|
109
|
+
return [int(code) for code in scenario.expected_status]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _response_schema_literal(scenario):
|
|
113
|
+
"""Return the response schema for positive scenarios, else None."""
|
|
114
|
+
if scenario.scenario_type == "positive":
|
|
115
|
+
return scenario.endpoint.response_schema
|
|
116
|
+
return None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{# Template for a generated pytest test file (one per resource tag). #}
|
|
2
|
+
{# Receives: tag, base_url, and a list of pre-rendered scenario dicts. #}
|
|
3
|
+
# {{ filename }}
|
|
4
|
+
# Generated by SwaggerForge - do not edit manually.
|
|
5
|
+
import pytest
|
|
6
|
+
import requests
|
|
7
|
+
import jsonschema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture(scope="session")
|
|
11
|
+
def base_url():
|
|
12
|
+
return "{{ base_url }}"
|
|
13
|
+
|
|
14
|
+
{% for scenario in scenarios %}
|
|
15
|
+
|
|
16
|
+
def {{ scenario.function_name }}(base_url):
|
|
17
|
+
"""{{ scenario.docstring }}"""
|
|
18
|
+
response = {{ scenario.request_call }}
|
|
19
|
+
assert response.status_code in {{ scenario.expected_status }}
|
|
20
|
+
{% if scenario.response_schema %}
|
|
21
|
+
schema = {{ scenario.response_schema }}
|
|
22
|
+
jsonschema.validate(instance=response.json(), schema=schema)
|
|
23
|
+
{% endif %}
|
|
24
|
+
{% endfor %}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Validation of OpenAPI specification files.
|
|
2
|
+
|
|
3
|
+
This module is responsible for reading an OpenAPI specification from disk
|
|
4
|
+
and verifying that it is structurally valid before any further processing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from openapi_spec_validator import validate
|
|
8
|
+
from openapi_spec_validator.readers import read_from_filename
|
|
9
|
+
from openapi_spec_validator.validation.exceptions import OpenAPIValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SpecValidationError(Exception):
|
|
13
|
+
"""Raised when an OpenAPI specification is invalid or cannot be read."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_and_validate(spec_path):
|
|
18
|
+
"""Read and validate an OpenAPI specification file.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
spec_path: Path to the OpenAPI specification file (JSON or YAML).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The validated specification as a dictionary.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
SpecValidationError: If the file cannot be read or is not a valid
|
|
28
|
+
OpenAPI specification.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
spec_dict, base_uri = read_from_filename(spec_path)
|
|
32
|
+
except Exception as error:
|
|
33
|
+
raise SpecValidationError(
|
|
34
|
+
f"Could not read specification file '{spec_path}': {error}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
validate(spec_dict)
|
|
39
|
+
except OpenAPIValidationError as error:
|
|
40
|
+
raise SpecValidationError(
|
|
41
|
+
f"Invalid OpenAPI specification '{spec_path}': {error.message}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return spec_dict
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: swaggerforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
|
|
5
|
+
Author: Viktor Pylypenko
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: openapi,swagger,pytest,test generation,api testing
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: click>=8.0
|
|
12
|
+
Requires-Dist: prance>=23.6.21.0
|
|
13
|
+
Requires-Dist: openapi-core>=0.18
|
|
14
|
+
Requires-Dist: openapi-spec-validator>=0.7
|
|
15
|
+
Requires-Dist: jinja2>=3.0
|
|
16
|
+
Requires-Dist: requests>=2.28
|
|
17
|
+
Requires-Dist: jsonschema>=4.0
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
20
|
+
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# SwaggerForge
|
|
24
|
+
|
|
25
|
+
Automatic pytest test generation from OpenAPI (Swagger) specifications.
|
|
26
|
+
|
|
27
|
+
SwaggerForge is a Python library and command-line tool that reads an OpenAPI
|
|
28
|
+
specification and generates ready-to-run pytest test files - one per resource
|
|
29
|
+
covering positive, negative, boundary, and boolean scenarios grounded in
|
|
30
|
+
established test-design techniques.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- Reads OpenAPI 3.x specifications in JSON or YAML
|
|
35
|
+
- Resolves `$ref` references automatically
|
|
36
|
+
- Generates one pytest file per resource tag
|
|
37
|
+
- Produces six scenario types per endpoint where applicable:
|
|
38
|
+
- **Positive** >>> valid request, expects a 2xx response and validates the
|
|
39
|
+
response schema
|
|
40
|
+
- **Missing required field** >>> omits a required field, expects 400
|
|
41
|
+
- **Wrong data type** >>> sends a mistyped field, expects 400/422
|
|
42
|
+
- **Nonexistent resource** >>> requests an unlikely identifier, expects 404
|
|
43
|
+
- **Boundary values** >>> tests values at and just beyond declared
|
|
44
|
+
numeric/length limits (Boundary Value Analysis)
|
|
45
|
+
- **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
|
|
46
|
+
- Deterministic output: the same specification always produces identical tests
|
|
47
|
+
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- Python 3.10 or newer
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install swaggerforge
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Generate tests from a specification, pointing at the base URL of the API
|
|
62
|
+
under test:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
swaggerforge generate --spec swagger.json --url http://localhost:8080
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This reads `swagger.json`, writes one `test_<resource>.py` file per resource
|
|
69
|
+
tag into the output directory (default: `tests_generated/`), and the files can
|
|
70
|
+
be run immediately:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pytest tests_generated
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Options
|
|
77
|
+
|
|
78
|
+
| Option | Description | Default |
|
|
79
|
+
|------------|----------------------------------------------------|--------------------|
|
|
80
|
+
| `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)* |
|
|
81
|
+
| `--url` | Base URL of the API under test | *(required)* |
|
|
82
|
+
| `--output` | Directory for the generated test files | `tests_generated` |
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
SwaggerForge runs a six-stage pipeline: the specification is validated, parsed
|
|
87
|
+
into an internal model (with `$ref`s resolved), turned into test scenarios
|
|
88
|
+
based on test-design techniques, rendered into pytest code via templates, and
|
|
89
|
+
written to per-resource files.
|
|
90
|
+
|
|
91
|
+
## Limitations
|
|
92
|
+
|
|
93
|
+
- Targets OpenAPI 3.x with JSON request bodies
|
|
94
|
+
- Authentication is not yet handled (planned)
|
|
95
|
+
- Boundary tests require the specification to declare numeric/length constraints
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
swaggerforge/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
swaggerforge/__main__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
swaggerforge/cli.py,sha256=wJSm4NuITUncwgQTwbbpYx1KiNXtKu94y4aU-Oxwv6E,2048
|
|
4
|
+
swaggerforge/generator.py,sha256=B7Jj9Uq-yx02n41v0boUIH8eINz4cnfHb3flmSx25c0,14979
|
|
5
|
+
swaggerforge/models.py,sha256=rlPOYSqcZFsn8glUCG4T5Mi3XFl8BvWoEAW-tXYQrbY,1263
|
|
6
|
+
swaggerforge/output.py,sha256=t_0Dms4-2Gp96Swo15VAs3Mfc87OODFzh8Ee_1zJTcA,1662
|
|
7
|
+
swaggerforge/parser.py,sha256=r4vUzafeq6KCbIpVJec5QEB4FaOt_HBXsOXqsfwJ7_A,3545
|
|
8
|
+
swaggerforge/template.py,sha256=sy6yH7xqcUFVIsrffu5iteOvemheFxdvT26CwNMqaSo,3920
|
|
9
|
+
swaggerforge/validator.py,sha256=Cyn6uhC5O8Poihm_HsSZ-yk2TtSAWgPsg1wNI6Ek_F8,1373
|
|
10
|
+
swaggerforge/templates/test_file.py.j2,sha256=zkJS2WlzmnRcHDVj3ovBRZzp56BKDK1Ba903Vefb_oQ,754
|
|
11
|
+
swaggerforge-0.1.0.dist-info/licenses/LICENSE,sha256=xEuAv8EHV44TLaTEgveWUaVa_iXumSw-UY8kBHhqpTg,1092
|
|
12
|
+
swaggerforge-0.1.0.dist-info/METADATA,sha256=QYYLgV3TBl3Ln6EQeHUyLtbJA3p1HX9uPcIYFBYsUy8,3543
|
|
13
|
+
swaggerforge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
swaggerforge-0.1.0.dist-info/entry_points.txt,sha256=KVzakVZtrieiY8HgXo_RVyZlTQUBfyJPB_jiUtuIZ6c,55
|
|
15
|
+
swaggerforge-0.1.0.dist-info/top_level.txt,sha256=ytRVBaDXndBGnlln9nkkxmYeIoHygfuV2-IO11mxnd4,13
|
|
16
|
+
swaggerforge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Viktor Pylypenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
swaggerforge
|