swaggerforge 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {swaggerforge-0.1.0/src/swaggerforge.egg-info → swaggerforge-0.2.0}/PKG-INFO +35 -7
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/README.md +31 -5
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/pyproject.toml +3 -3
- swaggerforge-0.2.0/src/swaggerforge/__main__.py +5 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/cli.py +29 -6
- swaggerforge-0.2.0/src/swaggerforge/config.py +102 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/output.py +2 -2
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/template.py +10 -5
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/templates/test_file.py.j2 +6 -1
- {swaggerforge-0.1.0 → swaggerforge-0.2.0/src/swaggerforge.egg-info}/PKG-INFO +35 -7
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge.egg-info/SOURCES.txt +2 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge.egg-info/requires.txt +5 -1
- swaggerforge-0.2.0/tests/test_config.py +120 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/tests/test_template.py +24 -0
- swaggerforge-0.1.0/src/swaggerforge/__main__.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/LICENSE +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/setup.cfg +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/__init__.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/generator.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/models.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/parser.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge/validator.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge.egg-info/entry_points.txt +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/src/swaggerforge.egg-info/top_level.txt +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/tests/test_generator.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/tests/test_output.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/tests/test_parser.py +0 -0
- {swaggerforge-0.1.0 → swaggerforge-0.2.0}/tests/test_validator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swaggerforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
|
|
5
5
|
Author: Viktor Pylypenko
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,14 +10,16 @@ Description-Content-Type: text/markdown
|
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Dist: click>=8.0
|
|
12
12
|
Requires-Dist: prance>=23.6.21.0
|
|
13
|
-
Requires-Dist: openapi-core>=0.18
|
|
14
13
|
Requires-Dist: openapi-spec-validator>=0.7
|
|
15
14
|
Requires-Dist: jinja2>=3.0
|
|
16
15
|
Requires-Dist: requests>=2.28
|
|
17
16
|
Requires-Dist: jsonschema>=4.0
|
|
17
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
20
20
|
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: tox>=4.0; extra == "dev"
|
|
21
23
|
Dynamic: license-file
|
|
22
24
|
|
|
23
25
|
# SwaggerForge
|
|
@@ -45,6 +47,7 @@ established test-design techniques.
|
|
|
45
47
|
- **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
|
|
46
48
|
- Deterministic output: the same specification always produces identical tests
|
|
47
49
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
50
|
+
- Optional `swaggerforge.toml` config file for project-level defaults
|
|
48
51
|
|
|
49
52
|
## Requirements
|
|
50
53
|
|
|
@@ -75,11 +78,36 @@ pytest tests_generated
|
|
|
75
78
|
|
|
76
79
|
### Options
|
|
77
80
|
|
|
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`
|
|
81
|
+
| Option | Description | Default |
|
|
82
|
+
|------------|----------------------------------------------------|-----------------------------------|
|
|
83
|
+
| `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)* |
|
|
84
|
+
| `--url` | Base URL of the API under test | *(required unless in config)* |
|
|
85
|
+
| `--output` | Directory for the generated test files | `tests_generated` |
|
|
86
|
+
| `--config` | Path to a configuration file | `./swaggerforge.toml` if present |
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
Options that stay the same across runs can be kept in a `swaggerforge.toml`
|
|
91
|
+
file instead of being passed on the command line. The file is picked up
|
|
92
|
+
automatically from the directory where the tool is run, or an explicit path
|
|
93
|
+
can be given with `--config`.
|
|
94
|
+
|
|
95
|
+
```toml
|
|
96
|
+
# swaggerforge.toml
|
|
97
|
+
base_url = "http://localhost:8080"
|
|
98
|
+
output_dir = "tests_generated"
|
|
99
|
+
timeout = 30
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Key | Type | Effect |
|
|
103
|
+
|--------------|---------|---------------------------------------------------------------|
|
|
104
|
+
| `base_url` | string | Base URL of the API; makes `--url` optional |
|
|
105
|
+
| `output_dir` | string | Directory for generated files |
|
|
106
|
+
| `timeout` | integer | Embeds `timeout=<n>` into every generated HTTP request call |
|
|
107
|
+
|
|
108
|
+
Values given on the command line always take precedence over the config file.
|
|
109
|
+
Without a `timeout`, generated tests place no time limit on requests -
|
|
110
|
+
setting one makes test runs fail fast when the API is unreachable.
|
|
83
111
|
|
|
84
112
|
## How it works
|
|
85
113
|
|
|
@@ -23,6 +23,7 @@ established test-design techniques.
|
|
|
23
23
|
- **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
|
|
24
24
|
- Deterministic output: the same specification always produces identical tests
|
|
25
25
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
26
|
+
- Optional `swaggerforge.toml` config file for project-level defaults
|
|
26
27
|
|
|
27
28
|
## Requirements
|
|
28
29
|
|
|
@@ -53,11 +54,36 @@ pytest tests_generated
|
|
|
53
54
|
|
|
54
55
|
### Options
|
|
55
56
|
|
|
56
|
-
| Option | Description | Default
|
|
57
|
-
|
|
58
|
-
| `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)*
|
|
59
|
-
| `--url` | Base URL of the API under test | *(required)*
|
|
60
|
-
| `--output` | Directory for the generated test files | `tests_generated`
|
|
57
|
+
| Option | Description | Default |
|
|
58
|
+
|------------|----------------------------------------------------|-----------------------------------|
|
|
59
|
+
| `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)* |
|
|
60
|
+
| `--url` | Base URL of the API under test | *(required unless in config)* |
|
|
61
|
+
| `--output` | Directory for the generated test files | `tests_generated` |
|
|
62
|
+
| `--config` | Path to a configuration file | `./swaggerforge.toml` if present |
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
Options that stay the same across runs can be kept in a `swaggerforge.toml`
|
|
67
|
+
file instead of being passed on the command line. The file is picked up
|
|
68
|
+
automatically from the directory where the tool is run, or an explicit path
|
|
69
|
+
can be given with `--config`.
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
# swaggerforge.toml
|
|
73
|
+
base_url = "http://localhost:8080"
|
|
74
|
+
output_dir = "tests_generated"
|
|
75
|
+
timeout = 30
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Key | Type | Effect |
|
|
79
|
+
|--------------|---------|---------------------------------------------------------------|
|
|
80
|
+
| `base_url` | string | Base URL of the API; makes `--url` optional |
|
|
81
|
+
| `output_dir` | string | Directory for generated files |
|
|
82
|
+
| `timeout` | integer | Embeds `timeout=<n>` into every generated HTTP request call |
|
|
83
|
+
|
|
84
|
+
Values given on the command line always take precedence over the config file.
|
|
85
|
+
Without a `timeout`, generated tests place no time limit on requests -
|
|
86
|
+
setting one makes test runs fail fast when the API is unreachable.
|
|
61
87
|
|
|
62
88
|
## How it works
|
|
63
89
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "swaggerforge"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Automatic pytest test generation from OpenAPI (Swagger) specifications"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -16,11 +16,11 @@ keywords = ["openapi", "swagger", "pytest", "test generation", "api testing"]
|
|
|
16
16
|
dependencies = [
|
|
17
17
|
"click>=8.0",
|
|
18
18
|
"prance>=23.6.21.0",
|
|
19
|
-
"openapi-core>=0.18",
|
|
20
19
|
"openapi-spec-validator>=0.7",
|
|
21
20
|
"jinja2>=3.0",
|
|
22
21
|
"requests>=2.28",
|
|
23
22
|
"jsonschema>=4.0",
|
|
23
|
+
'tomli>=2.0; python_version < "3.11"',
|
|
24
24
|
]
|
|
25
25
|
|
|
26
26
|
[project.scripts]
|
|
@@ -38,4 +38,4 @@ addopts = "--import-mode=importlib"
|
|
|
38
38
|
testpaths = ["tests"]
|
|
39
39
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
|
-
dev = ["pytest>=9.0", "flake8>=7.0"]
|
|
41
|
+
dev = ["pytest>=9.0", "flake8>=7.0", "pytest-cov>=7.0", "tox>=4.0"]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Command-line interface for SwaggerForge."""
|
|
2
2
|
|
|
3
3
|
import click
|
|
4
|
+
from swaggerforge.config import load_config, ConfigError
|
|
4
5
|
from swaggerforge.validator import load_and_validate, SpecValidationError
|
|
5
6
|
from swaggerforge.parser import parse_spec, SpecParseError
|
|
6
7
|
from swaggerforge.generator import generate_scenarios
|
|
@@ -23,17 +24,39 @@ def main():
|
|
|
23
24
|
)
|
|
24
25
|
@click.option(
|
|
25
26
|
"--url",
|
|
26
|
-
|
|
27
|
-
help="Base URL of the API to be tested."
|
|
27
|
+
default=None,
|
|
28
|
+
help="Base URL of the API to be tested. "
|
|
29
|
+
"Required unless base_url is set in the config file.",
|
|
28
30
|
)
|
|
29
31
|
@click.option(
|
|
30
32
|
"--output",
|
|
31
|
-
default=
|
|
33
|
+
default=None,
|
|
32
34
|
type=click.Path(),
|
|
33
|
-
help="Directory where generated test files will be written
|
|
35
|
+
help="Directory where generated test files will be written "
|
|
36
|
+
"[default: tests_generated].",
|
|
34
37
|
)
|
|
35
|
-
|
|
38
|
+
@click.option(
|
|
39
|
+
"--config",
|
|
40
|
+
"config_path",
|
|
41
|
+
default=None,
|
|
42
|
+
type=click.Path(),
|
|
43
|
+
help="Path to a swaggerforge.toml config file "
|
|
44
|
+
"[default: ./swaggerforge.toml if present].",
|
|
45
|
+
)
|
|
46
|
+
def generate(spec, url, output, config_path):
|
|
36
47
|
"""Generate pytest test files from an OpenAPI specification."""
|
|
48
|
+
try:
|
|
49
|
+
config = load_config(config_path)
|
|
50
|
+
except ConfigError as error:
|
|
51
|
+
raise click.ClickException(str(error))
|
|
52
|
+
|
|
53
|
+
url = url or config.base_url
|
|
54
|
+
if url is None:
|
|
55
|
+
raise click.ClickException(
|
|
56
|
+
"No base URL given: pass --url or set base_url in swaggerforge.toml."
|
|
57
|
+
)
|
|
58
|
+
output = output or config.output_dir or "tests_generated"
|
|
59
|
+
|
|
37
60
|
click.echo(f"Reading specification: {spec}")
|
|
38
61
|
|
|
39
62
|
try:
|
|
@@ -56,7 +79,7 @@ def generate(spec, url, output):
|
|
|
56
79
|
click.echo(f"Generated {len(scenarios)} test scenarios.")
|
|
57
80
|
|
|
58
81
|
try:
|
|
59
|
-
written = write_test_files(scenarios, url, output)
|
|
82
|
+
written = write_test_files(scenarios, url, output, config.timeout)
|
|
60
83
|
except OSError as error:
|
|
61
84
|
raise click.ClickException(
|
|
62
85
|
f"Could not write test files to '{output}': {error}"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Loading of user configuration from a swaggerforge.toml file.
|
|
2
|
+
|
|
3
|
+
This module reads optional user configuration for the generator. An
|
|
4
|
+
explicitly given path must exist and parse; an auto-discovered file
|
|
5
|
+
(./swaggerforge.toml in the working directory) may be absent, in which
|
|
6
|
+
case an empty configuration is returned.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import tomllib # Python 3.11+
|
|
14
|
+
except ModuleNotFoundError:
|
|
15
|
+
import tomli as tomllib # Python 3.10 backport
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG_FILENAME = "swaggerforge.toml"
|
|
18
|
+
|
|
19
|
+
_KNOWN_KEYS = {
|
|
20
|
+
"timeout": int,
|
|
21
|
+
"output_dir": str,
|
|
22
|
+
"base_url": str,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""Raised when a config file is missing (if explicitly requested) or invalid."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
"""User configuration loaded from swaggerforge.toml.
|
|
33
|
+
|
|
34
|
+
A field value of None means the setting was not present in the file.
|
|
35
|
+
"""
|
|
36
|
+
timeout: int | None = None
|
|
37
|
+
output_dir: str | None = None
|
|
38
|
+
base_url: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_config(config_path=None):
|
|
42
|
+
"""Load configuration from a swaggerforge.toml file.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
config_path: Explicit path to a config file. If given, the file
|
|
46
|
+
must exist and parse; otherwise ./swaggerforge.toml is
|
|
47
|
+
auto-discovered, and its absence yields an empty Config.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A Config object with values from the file, or an empty Config
|
|
51
|
+
when no file was found during auto-discovery.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ConfigError: If an explicitly given file is missing, if any file
|
|
55
|
+
contains invalid TOML, or if it contains unknown keys or
|
|
56
|
+
values of the wrong type.
|
|
57
|
+
"""
|
|
58
|
+
if config_path is not None:
|
|
59
|
+
path = Path(config_path)
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
raise ConfigError(f"Config file not found: '{config_path}'")
|
|
62
|
+
else:
|
|
63
|
+
path = Path(DEFAULT_CONFIG_FILENAME)
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return Config()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
raw = path.read_bytes()
|
|
69
|
+
# utf-8-sig strips a leading UTF-8 BOM if present (common on
|
|
70
|
+
# Windows) and is identical to utf-8 otherwise.
|
|
71
|
+
data = tomllib.loads(raw.decode("utf-8-sig"))
|
|
72
|
+
except tomllib.TOMLDecodeError as error:
|
|
73
|
+
raise ConfigError(f"Invalid config file '{path}': {error}")
|
|
74
|
+
except UnicodeDecodeError:
|
|
75
|
+
raise ConfigError(
|
|
76
|
+
f"Invalid config file '{path}': not valid UTF-8 "
|
|
77
|
+
f"(TOML files must be UTF-8 encoded; on Windows, beware that "
|
|
78
|
+
f"PowerShell redirection may create UTF-16 files)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return _validate(data, path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate(data, path):
|
|
85
|
+
"""Check keys and value types, returning a populated Config."""
|
|
86
|
+
unknown = set(data) - set(_KNOWN_KEYS)
|
|
87
|
+
if unknown:
|
|
88
|
+
names = ", ".join(sorted(unknown))
|
|
89
|
+
raise ConfigError(f"Unknown key(s) in config file '{path}': {names}")
|
|
90
|
+
|
|
91
|
+
for key, expected_type in _KNOWN_KEYS.items():
|
|
92
|
+
if key not in data:
|
|
93
|
+
continue
|
|
94
|
+
value = data[key]
|
|
95
|
+
# bool is a subclass of int in Python; reject it for timeout.
|
|
96
|
+
if isinstance(value, bool) or not isinstance(value, expected_type):
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"Invalid value for '{key}' in config file '{path}': "
|
|
99
|
+
f"expected {expected_type.__name__}, got {type(value).__name__}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return Config(**data)
|
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from swaggerforge.template import render_test_file
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def write_test_files(scenarios, base_url, output_dir):
|
|
15
|
+
def write_test_files(scenarios, base_url, output_dir, timeout=None):
|
|
16
16
|
"""Group scenarios by tag, render them, and write one file per tag.
|
|
17
17
|
|
|
18
18
|
Args:
|
|
@@ -32,7 +32,7 @@ def write_test_files(scenarios, base_url, output_dir):
|
|
|
32
32
|
for tag in sorted(grouped):
|
|
33
33
|
filename = f"test_{_sanitize(tag)}.py"
|
|
34
34
|
file_path = output_path / filename
|
|
35
|
-
content = render_test_file(filename, base_url, grouped[tag])
|
|
35
|
+
content = render_test_file(filename, base_url, grouped[tag], timeout)
|
|
36
36
|
file_path.write_text(content, encoding="utf-8")
|
|
37
37
|
written.append(str(file_path))
|
|
38
38
|
|
|
@@ -17,19 +17,21 @@ _env = Environment(
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def render_test_file(filename, base_url, scenarios):
|
|
20
|
+
def render_test_file(filename, base_url, scenarios, timeout=None):
|
|
21
21
|
"""Render a list of scenarios into the text of a pytest test file.
|
|
22
22
|
|
|
23
23
|
Args:
|
|
24
24
|
filename: The name of the file being generated (e.g. 'test_pet.py').
|
|
25
25
|
base_url: The base URL of the API under test.
|
|
26
26
|
scenarios: A list of TestScenario objects for one resource tag.
|
|
27
|
+
timeout: Optional request timeout in seconds; when given, it is
|
|
28
|
+
embedded into every generated HTTP call.
|
|
27
29
|
|
|
28
30
|
Returns:
|
|
29
31
|
The rendered file content as a string.
|
|
30
32
|
"""
|
|
31
33
|
template = _env.get_template("test_file.py.j2")
|
|
32
|
-
rendered_scenarios = [_prepare_scenario(s) for s in scenarios]
|
|
34
|
+
rendered_scenarios = [_prepare_scenario(s, timeout) for s in scenarios]
|
|
33
35
|
return template.render(
|
|
34
36
|
filename=filename,
|
|
35
37
|
base_url=base_url,
|
|
@@ -37,12 +39,12 @@ def render_test_file(filename, base_url, scenarios):
|
|
|
37
39
|
)
|
|
38
40
|
|
|
39
41
|
|
|
40
|
-
def _prepare_scenario(scenario):
|
|
42
|
+
def _prepare_scenario(scenario, timeout=None):
|
|
41
43
|
"""Pre-compute all values the template needs for one scenario."""
|
|
42
44
|
return {
|
|
43
45
|
"function_name": build_function_name(scenario),
|
|
44
46
|
"docstring": scenario.description,
|
|
45
|
-
"request_call": build_request_call(scenario),
|
|
47
|
+
"request_call": build_request_call(scenario, timeout),
|
|
46
48
|
"expected_status": _status_codes_literal(scenario),
|
|
47
49
|
"response_schema": _response_schema_literal(scenario),
|
|
48
50
|
}
|
|
@@ -81,16 +83,19 @@ def _path_to_identifier(method, path):
|
|
|
81
83
|
return f"{method}_{cleaned}"
|
|
82
84
|
|
|
83
85
|
|
|
84
|
-
def build_request_call(scenario):
|
|
86
|
+
def build_request_call(scenario, timeout=None):
|
|
85
87
|
"""Assemble the full requests.<method>(...) call as a string."""
|
|
86
88
|
endpoint = scenario.endpoint
|
|
87
89
|
url = _build_url(endpoint.path, scenario.path_params)
|
|
88
90
|
|
|
89
91
|
parts = [f'f"{{base_url}}{url}"']
|
|
92
|
+
parts.append("headers=headers")
|
|
90
93
|
if scenario.query_params:
|
|
91
94
|
parts.append(f"params={scenario.query_params!r}")
|
|
92
95
|
if scenario.request_body is not None:
|
|
93
96
|
parts.append(f"json={scenario.request_body!r}")
|
|
97
|
+
if timeout is not None:
|
|
98
|
+
parts.append(f"timeout={timeout}")
|
|
94
99
|
|
|
95
100
|
args = ", ".join(parts)
|
|
96
101
|
return f"requests.{endpoint.method}({args})"
|
|
@@ -11,9 +11,14 @@ import jsonschema
|
|
|
11
11
|
def base_url():
|
|
12
12
|
return "{{ base_url }}"
|
|
13
13
|
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(scope="session")
|
|
16
|
+
def headers():
|
|
17
|
+
return {"Content-Type": "application/json"}
|
|
18
|
+
|
|
14
19
|
{% for scenario in scenarios %}
|
|
15
20
|
|
|
16
|
-
def {{ scenario.function_name }}(base_url):
|
|
21
|
+
def {{ scenario.function_name }}(base_url, headers):
|
|
17
22
|
"""{{ scenario.docstring }}"""
|
|
18
23
|
response = {{ scenario.request_call }}
|
|
19
24
|
assert response.status_code in {{ scenario.expected_status }}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swaggerforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
|
|
5
5
|
Author: Viktor Pylypenko
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,14 +10,16 @@ Description-Content-Type: text/markdown
|
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Requires-Dist: click>=8.0
|
|
12
12
|
Requires-Dist: prance>=23.6.21.0
|
|
13
|
-
Requires-Dist: openapi-core>=0.18
|
|
14
13
|
Requires-Dist: openapi-spec-validator>=0.7
|
|
15
14
|
Requires-Dist: jinja2>=3.0
|
|
16
15
|
Requires-Dist: requests>=2.28
|
|
17
16
|
Requires-Dist: jsonschema>=4.0
|
|
17
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
20
20
|
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: tox>=4.0; extra == "dev"
|
|
21
23
|
Dynamic: license-file
|
|
22
24
|
|
|
23
25
|
# SwaggerForge
|
|
@@ -45,6 +47,7 @@ established test-design techniques.
|
|
|
45
47
|
- **Boolean coverage** >>> exercises both `true` and `false` for boolean fields
|
|
46
48
|
- Deterministic output: the same specification always produces identical tests
|
|
47
49
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
50
|
+
- Optional `swaggerforge.toml` config file for project-level defaults
|
|
48
51
|
|
|
49
52
|
## Requirements
|
|
50
53
|
|
|
@@ -75,11 +78,36 @@ pytest tests_generated
|
|
|
75
78
|
|
|
76
79
|
### Options
|
|
77
80
|
|
|
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`
|
|
81
|
+
| Option | Description | Default |
|
|
82
|
+
|------------|----------------------------------------------------|-----------------------------------|
|
|
83
|
+
| `--spec` | Path to the OpenAPI specification (JSON or YAML) | *(required)* |
|
|
84
|
+
| `--url` | Base URL of the API under test | *(required unless in config)* |
|
|
85
|
+
| `--output` | Directory for the generated test files | `tests_generated` |
|
|
86
|
+
| `--config` | Path to a configuration file | `./swaggerforge.toml` if present |
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
Options that stay the same across runs can be kept in a `swaggerforge.toml`
|
|
91
|
+
file instead of being passed on the command line. The file is picked up
|
|
92
|
+
automatically from the directory where the tool is run, or an explicit path
|
|
93
|
+
can be given with `--config`.
|
|
94
|
+
|
|
95
|
+
```toml
|
|
96
|
+
# swaggerforge.toml
|
|
97
|
+
base_url = "http://localhost:8080"
|
|
98
|
+
output_dir = "tests_generated"
|
|
99
|
+
timeout = 30
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Key | Type | Effect |
|
|
103
|
+
|--------------|---------|---------------------------------------------------------------|
|
|
104
|
+
| `base_url` | string | Base URL of the API; makes `--url` optional |
|
|
105
|
+
| `output_dir` | string | Directory for generated files |
|
|
106
|
+
| `timeout` | integer | Embeds `timeout=<n>` into every generated HTTP request call |
|
|
107
|
+
|
|
108
|
+
Values given on the command line always take precedence over the config file.
|
|
109
|
+
Without a `timeout`, generated tests place no time limit on requests -
|
|
110
|
+
setting one makes test runs fail fast when the API is unreachable.
|
|
83
111
|
|
|
84
112
|
## How it works
|
|
85
113
|
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/swaggerforge/__init__.py
|
|
5
5
|
src/swaggerforge/__main__.py
|
|
6
6
|
src/swaggerforge/cli.py
|
|
7
|
+
src/swaggerforge/config.py
|
|
7
8
|
src/swaggerforge/generator.py
|
|
8
9
|
src/swaggerforge/models.py
|
|
9
10
|
src/swaggerforge/output.py
|
|
@@ -17,6 +18,7 @@ src/swaggerforge.egg-info/entry_points.txt
|
|
|
17
18
|
src/swaggerforge.egg-info/requires.txt
|
|
18
19
|
src/swaggerforge.egg-info/top_level.txt
|
|
19
20
|
src/swaggerforge/templates/test_file.py.j2
|
|
21
|
+
tests/test_config.py
|
|
20
22
|
tests/test_generator.py
|
|
21
23
|
tests/test_output.py
|
|
22
24
|
tests/test_parser.py
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
click>=8.0
|
|
2
2
|
prance>=23.6.21.0
|
|
3
|
-
openapi-core>=0.18
|
|
4
3
|
openapi-spec-validator>=0.7
|
|
5
4
|
jinja2>=3.0
|
|
6
5
|
requests>=2.28
|
|
7
6
|
jsonschema>=4.0
|
|
8
7
|
|
|
8
|
+
[:python_version < "3.11"]
|
|
9
|
+
tomli>=2.0
|
|
10
|
+
|
|
9
11
|
[dev]
|
|
10
12
|
pytest>=9.0
|
|
11
13
|
flake8>=7.0
|
|
14
|
+
pytest-cov>=7.0
|
|
15
|
+
tox>=4.0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tests for the config module."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from swaggerforge.config import Config, ConfigError, load_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VALID_TOML = """\
|
|
9
|
+
timeout = 30
|
|
10
|
+
output_dir = "my_tests"
|
|
11
|
+
base_url = "http://localhost:8080"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_load_explicit_valid_file(tmp_path):
|
|
16
|
+
"""An explicitly given, valid config file is loaded fully."""
|
|
17
|
+
config_file = tmp_path / "custom.toml"
|
|
18
|
+
config_file.write_text(VALID_TOML, encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
config = load_config(str(config_file))
|
|
21
|
+
|
|
22
|
+
assert config.timeout == 30
|
|
23
|
+
assert config.output_dir == "my_tests"
|
|
24
|
+
assert config.base_url == "http://localhost:8080"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_load_explicit_missing_file_raises(tmp_path):
|
|
28
|
+
"""An explicitly given path that does not exist is an error."""
|
|
29
|
+
missing = tmp_path / "nope.toml"
|
|
30
|
+
|
|
31
|
+
with pytest.raises(ConfigError):
|
|
32
|
+
load_config(str(missing))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_load_explicit_malformed_file_raises(tmp_path):
|
|
36
|
+
"""An explicitly given file with invalid TOML is an error."""
|
|
37
|
+
config_file = tmp_path / "broken.toml"
|
|
38
|
+
config_file.write_text("]] this is not toml [[", encoding="utf-8")
|
|
39
|
+
|
|
40
|
+
with pytest.raises(ConfigError):
|
|
41
|
+
load_config(str(config_file))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_autodiscovery_finds_file_in_cwd(tmp_path, monkeypatch):
|
|
45
|
+
"""Without an explicit path, ./swaggerforge.toml is discovered."""
|
|
46
|
+
(tmp_path / "swaggerforge.toml").write_text(VALID_TOML, encoding="utf-8")
|
|
47
|
+
monkeypatch.chdir(tmp_path)
|
|
48
|
+
|
|
49
|
+
config = load_config()
|
|
50
|
+
|
|
51
|
+
assert config.timeout == 30
|
|
52
|
+
assert config.output_dir == "my_tests"
|
|
53
|
+
assert config.base_url == "http://localhost:8080"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_autodiscovery_absent_returns_empty_config(tmp_path, monkeypatch):
|
|
57
|
+
"""No explicit path and no discoverable file yields an empty config."""
|
|
58
|
+
monkeypatch.chdir(tmp_path)
|
|
59
|
+
|
|
60
|
+
config = load_config()
|
|
61
|
+
|
|
62
|
+
assert config == Config()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_autodiscovery_malformed_raises(tmp_path, monkeypatch):
|
|
66
|
+
"""A discovered file that exists but is invalid TOML is an error."""
|
|
67
|
+
(tmp_path / "swaggerforge.toml").write_text("]] broken [[", encoding="utf-8")
|
|
68
|
+
monkeypatch.chdir(tmp_path)
|
|
69
|
+
|
|
70
|
+
with pytest.raises(ConfigError):
|
|
71
|
+
load_config()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_partial_config_leaves_other_fields_none(tmp_path):
|
|
75
|
+
"""Keys absent from the file stay None (unset)."""
|
|
76
|
+
config_file = tmp_path / "partial.toml"
|
|
77
|
+
config_file.write_text("timeout = 10\n", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
config = load_config(str(config_file))
|
|
80
|
+
|
|
81
|
+
assert config.timeout == 10
|
|
82
|
+
assert config.output_dir is None
|
|
83
|
+
assert config.base_url is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_unknown_key_raises(tmp_path):
|
|
87
|
+
"""An unrecognized key (e.g. a typo) is an error, not silently ignored."""
|
|
88
|
+
config_file = tmp_path / "typo.toml"
|
|
89
|
+
config_file.write_text("timout = 10\n", encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
with pytest.raises(ConfigError):
|
|
92
|
+
load_config(str(config_file))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_wrong_value_type_raises(tmp_path):
|
|
96
|
+
"""A value of the wrong type (string where int expected) is an error."""
|
|
97
|
+
config_file = tmp_path / "badtype.toml"
|
|
98
|
+
config_file.write_text('timeout = "thirty"\n', encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
with pytest.raises(ConfigError):
|
|
101
|
+
load_config(str(config_file))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_non_utf8_file_raises_config_error(tmp_path):
|
|
105
|
+
"""A config file that is not valid UTF-8 is a ConfigError, not a crash."""
|
|
106
|
+
config_file = tmp_path / "utf16.toml"
|
|
107
|
+
config_file.write_text("timeout = 30\n", encoding="utf-16")
|
|
108
|
+
|
|
109
|
+
with pytest.raises(ConfigError):
|
|
110
|
+
load_config(str(config_file))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_utf8_bom_file_is_tolerated(tmp_path):
|
|
114
|
+
"""A UTF-8 file with a BOM (common on Windows) loads normally."""
|
|
115
|
+
config_file = tmp_path / "bom.toml"
|
|
116
|
+
config_file.write_text("timeout = 30\n", encoding="utf-8-sig")
|
|
117
|
+
|
|
118
|
+
config = load_config(str(config_file))
|
|
119
|
+
|
|
120
|
+
assert config.timeout == 30
|
|
@@ -71,3 +71,27 @@ def test_rendered_file_has_no_duplicate_functions():
|
|
|
71
71
|
assert len(function_names) == len(set(function_names)), (
|
|
72
72
|
"Rendered file contains duplicate function definitions"
|
|
73
73
|
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_build_request_call_without_timeout_unchanged():
|
|
77
|
+
"""Without a timeout, the call contains no timeout argument."""
|
|
78
|
+
scenarios = _scenarios_for_tag("pet")
|
|
79
|
+
call = build_request_call(scenarios[0])
|
|
80
|
+
assert "timeout" not in call
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_build_request_call_with_timeout():
|
|
84
|
+
"""With a timeout, the call ends with a timeout keyword argument."""
|
|
85
|
+
scenarios = _scenarios_for_tag("pet")
|
|
86
|
+
call = build_request_call(scenarios[0], timeout=30)
|
|
87
|
+
assert "timeout=30" in call
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_render_test_file_threads_timeout():
|
|
91
|
+
"""A timeout given to render_test_file appears in every request call."""
|
|
92
|
+
scenarios = _scenarios_for_tag("pet")
|
|
93
|
+
rendered = render_test_file(
|
|
94
|
+
"test_pet.py", "http://localhost:8080", scenarios, timeout=15
|
|
95
|
+
)
|
|
96
|
+
compile(rendered, "test_pet.py", "exec")
|
|
97
|
+
assert rendered.count("timeout=15") == len(scenarios)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|