auto-api-test 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.
- auto_api_test/__init__.py +3 -0
- auto_api_test/assertions.py +158 -0
- auto_api_test/cli.py +142 -0
- auto_api_test/issue.py +175 -0
- auto_api_test/parser.py +118 -0
- auto_api_test/plugin.py +193 -0
- auto_api_test/reporter.py +115 -0
- auto_api_test/runner.py +164 -0
- auto_api_test-0.1.0.dist-info/METADATA +261 -0
- auto_api_test-0.1.0.dist-info/RECORD +14 -0
- auto_api_test-0.1.0.dist-info/WHEEL +5 -0
- auto_api_test-0.1.0.dist-info/entry_points.txt +5 -0
- auto_api_test-0.1.0.dist-info/licenses/LICENSE +73 -0
- auto_api_test-0.1.0.dist-info/top_level.txt +1 -0
auto_api_test/plugin.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""pytest plugin: collects YAML test files and registers them as test items."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from .parser import load_yaml, get_config, get_tests
|
|
8
|
+
from .runner import Runner, TestResult
|
|
9
|
+
from .reporter import generate_report, collect_failures
|
|
10
|
+
from .issue import create_issues_from_failures
|
|
11
|
+
|
|
12
|
+
# Module-level caches
|
|
13
|
+
_yaml_cache: dict[str, dict] = {}
|
|
14
|
+
_file_configs: dict[str, dict] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pytest_collect_file(parent, file_path):
|
|
18
|
+
"""Collect .yaml and .yml files as test modules."""
|
|
19
|
+
if file_path.suffix in (".yaml", ".yml"):
|
|
20
|
+
return YamlTestFile.from_parent(parent, path=file_path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class YamlTestFile(pytest.File):
|
|
24
|
+
"""A YAML file containing API test cases."""
|
|
25
|
+
|
|
26
|
+
def collect(self):
|
|
27
|
+
try:
|
|
28
|
+
data = load_yaml(self.path)
|
|
29
|
+
_yaml_cache[str(self.path)] = data
|
|
30
|
+
except Exception as e:
|
|
31
|
+
item = YamlTestItem.from_parent(self, name="parse_error")
|
|
32
|
+
item._error = str(e)
|
|
33
|
+
item._test_data = None
|
|
34
|
+
item._config = {}
|
|
35
|
+
item._index = 0
|
|
36
|
+
yield item
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
config = get_config(data)
|
|
40
|
+
_file_configs[str(self.path)] = config
|
|
41
|
+
tests = get_tests(data)
|
|
42
|
+
for i, test in enumerate(tests):
|
|
43
|
+
item = YamlTestItem.from_parent(self, name=test["name"])
|
|
44
|
+
item._error = None
|
|
45
|
+
item._test_data = test
|
|
46
|
+
item._config = config
|
|
47
|
+
item._index = i
|
|
48
|
+
yield item
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class YamlTestItem(pytest.Item):
|
|
52
|
+
"""A single YAML API test case."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, name, parent):
|
|
55
|
+
super().__init__(name, parent)
|
|
56
|
+
self._error: str | None = None
|
|
57
|
+
self._test_data: dict | None = None
|
|
58
|
+
self._config: dict = {}
|
|
59
|
+
self._index: int = 0
|
|
60
|
+
self._result: TestResult | None = None
|
|
61
|
+
|
|
62
|
+
def runtest(self):
|
|
63
|
+
if self._error:
|
|
64
|
+
raise YamlTestError(self._error)
|
|
65
|
+
|
|
66
|
+
runner = self._get_or_create_runner()
|
|
67
|
+
result = runner.run_tests([self._test_data])[0]
|
|
68
|
+
self._result = result
|
|
69
|
+
|
|
70
|
+
if not hasattr(self.session.config, "_atr_results"):
|
|
71
|
+
self.session.config._atr_results = []
|
|
72
|
+
self.session.config._atr_results.append(result)
|
|
73
|
+
|
|
74
|
+
if result.is_error:
|
|
75
|
+
raise YamlTestError(result.error)
|
|
76
|
+
if not result.passed:
|
|
77
|
+
raise YamlTestAssertionError(result.failures)
|
|
78
|
+
|
|
79
|
+
def _get_or_create_runner(self):
|
|
80
|
+
"""Get or create a Runner shared across tests in the same file."""
|
|
81
|
+
cache_key = f"_yaml_runner_{self.fspath}"
|
|
82
|
+
runner = getattr(self.session, cache_key, None)
|
|
83
|
+
if runner is None:
|
|
84
|
+
config = dict(self._config)
|
|
85
|
+
opt = getattr(self.session.config, "option", None)
|
|
86
|
+
if opt and getattr(opt, "base_url", None):
|
|
87
|
+
config["base_url"] = opt.base_url
|
|
88
|
+
runner = Runner(config)
|
|
89
|
+
setattr(self.session, cache_key, runner)
|
|
90
|
+
return runner
|
|
91
|
+
|
|
92
|
+
def repr_failure(self, excinfo):
|
|
93
|
+
if isinstance(excinfo.value, YamlTestError):
|
|
94
|
+
return f"ERROR: {excinfo.value}"
|
|
95
|
+
if isinstance(excinfo.value, YamlTestAssertionError):
|
|
96
|
+
lines = ["Assertion failures:"]
|
|
97
|
+
for f in excinfo.value.failures:
|
|
98
|
+
lines.append(f" - {f}")
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
return super().repr_failure(excinfo)
|
|
101
|
+
|
|
102
|
+
def reportinfo(self):
|
|
103
|
+
return self.fspath, None, self.name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class YamlTestError(Exception):
|
|
107
|
+
"""Raised when a test encounters an error (network, parse, etc.)."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class YamlTestAssertionError(Exception):
|
|
111
|
+
"""Raised when test assertions fail."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, failures: list[str]):
|
|
114
|
+
self.failures = failures
|
|
115
|
+
super().__init__("\n".join(failures))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def pytest_addoption(parser):
|
|
119
|
+
"""Add custom CLI options for YAML API testing."""
|
|
120
|
+
parser.addoption("--report", default=None, help="Markdown report output path")
|
|
121
|
+
parser.addoption("--base-url", default=None, help="Override base_url for all tests")
|
|
122
|
+
parser.addoption("--env-file", default=None, help="Load environment variables from file")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pytest_configure(config):
|
|
126
|
+
"""Load env-file if specified."""
|
|
127
|
+
env_file = config.getoption("env_file", None)
|
|
128
|
+
if env_file:
|
|
129
|
+
from dotenv import load_dotenv
|
|
130
|
+
load_dotenv(env_file)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
134
|
+
"""Generate report and optionally create issues after all tests complete."""
|
|
135
|
+
results = getattr(session.config, "_atr_results", [])
|
|
136
|
+
if not results:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Determine report path: CLI override > YAML file parent dir / report.md
|
|
140
|
+
report_path = session.config.getoption("report", None)
|
|
141
|
+
if not report_path:
|
|
142
|
+
for item in session.items:
|
|
143
|
+
if isinstance(item, YamlTestItem):
|
|
144
|
+
report_path = str(item.path.parent / "report.md")
|
|
145
|
+
break
|
|
146
|
+
if not report_path:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
report_content = generate_report(results, report_path)
|
|
150
|
+
|
|
151
|
+
# Auto-create issues if configured in any YAML file
|
|
152
|
+
_maybe_create_issues(session, results)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _maybe_create_issues(session, results: list[TestResult]):
|
|
156
|
+
"""Create git platform issues for failed tests if configured."""
|
|
157
|
+
failures = collect_failures(results)
|
|
158
|
+
if not failures:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
for item in session.items:
|
|
162
|
+
if not isinstance(item, YamlTestItem):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
config = _file_configs.get(str(item.path), {})
|
|
166
|
+
issue_cfg = config.get("issue", {})
|
|
167
|
+
if not issue_cfg.get("enabled"):
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
platform = issue_cfg.get("platform")
|
|
171
|
+
repo = issue_cfg.get("repo")
|
|
172
|
+
if not platform or not repo:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
base_url = issue_cfg.get("base_url", "")
|
|
176
|
+
labels = issue_cfg.get("labels")
|
|
177
|
+
|
|
178
|
+
# Only create issues for failures from this file
|
|
179
|
+
file_failures = [
|
|
180
|
+
f for f in failures
|
|
181
|
+
if any(r.name == f["name"] and not r.passed for r in results if r.name == f["name"])
|
|
182
|
+
]
|
|
183
|
+
if not file_failures:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
created = create_issues_from_failures(
|
|
188
|
+
file_failures, platform, repo, base_url, labels
|
|
189
|
+
)
|
|
190
|
+
for issue in created:
|
|
191
|
+
print(f" Created issue: {issue.get('url', issue)}")
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f" Warning: failed to create issues: {e}")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Markdown test report generator."""
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .runner import TestResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_report(results: list["TestResult"], output_path: str | Path) -> str:
|
|
12
|
+
"""Generate a Markdown test report from test results.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
results: List of test results.
|
|
16
|
+
output_path: Absolute path for the report file.
|
|
17
|
+
"""
|
|
18
|
+
total = len(results)
|
|
19
|
+
passed = sum(1 for r in results if r.passed)
|
|
20
|
+
failed = total - passed
|
|
21
|
+
errors = sum(1 for r in results if r.is_error)
|
|
22
|
+
pass_rate = f"{passed / total * 100:.1f}%" if total else "0%"
|
|
23
|
+
total_duration = sum(r.duration_ms for r in results)
|
|
24
|
+
|
|
25
|
+
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
26
|
+
|
|
27
|
+
lines = [
|
|
28
|
+
"# API Test Report",
|
|
29
|
+
"",
|
|
30
|
+
f"**Generated:** {now}",
|
|
31
|
+
"",
|
|
32
|
+
"## Summary",
|
|
33
|
+
"",
|
|
34
|
+
"| Metric | Value |",
|
|
35
|
+
"|--------|-------|",
|
|
36
|
+
f"| Total | {total} |",
|
|
37
|
+
f"| Passed | {passed} |",
|
|
38
|
+
f"| Failed | {failed - errors} |",
|
|
39
|
+
f"| Errors | {errors} |",
|
|
40
|
+
f"| Pass Rate | {pass_rate} |",
|
|
41
|
+
f"| Total Duration | {total_duration:.2f} ms |",
|
|
42
|
+
"",
|
|
43
|
+
"## Test Results",
|
|
44
|
+
"",
|
|
45
|
+
"| # | Name | Method | URL | Status | Duration | Result |",
|
|
46
|
+
"|---|------|--------|-----|--------|----------|--------|",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
for i, r in enumerate(results, 1):
|
|
50
|
+
status = str(r.status_code) if r.status_code else "-"
|
|
51
|
+
result_str = "✅ PASS" if r.passed else ("❌ ERROR" if r.is_error else "❌ FAIL")
|
|
52
|
+
url_short = r.request_url
|
|
53
|
+
if len(url_short) > 60:
|
|
54
|
+
url_short = url_short[:57] + "..."
|
|
55
|
+
lines.append(
|
|
56
|
+
f"| {i} | {r.name} | {r.request_method} | {url_short} | {status} | {r.duration_ms:.2f} ms | {result_str} |"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Failure details
|
|
60
|
+
failed_results = [r for r in results if not r.passed]
|
|
61
|
+
if failed_results:
|
|
62
|
+
lines.extend(["", "## Failure Details", ""])
|
|
63
|
+
for r in failed_results:
|
|
64
|
+
lines.append(f"### {r.name}")
|
|
65
|
+
lines.append("")
|
|
66
|
+
if r.is_error:
|
|
67
|
+
lines.append(f"**Error:** `{r.error}`")
|
|
68
|
+
else:
|
|
69
|
+
for f in r.failures:
|
|
70
|
+
lines.append(f"- {f}")
|
|
71
|
+
if r.response_body:
|
|
72
|
+
lines.extend([
|
|
73
|
+
"",
|
|
74
|
+
"<details><summary>Response Body</summary>",
|
|
75
|
+
"",
|
|
76
|
+
"```json",
|
|
77
|
+
r.response_body[:1000],
|
|
78
|
+
"```",
|
|
79
|
+
"",
|
|
80
|
+
"</details>",
|
|
81
|
+
])
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
report = "\n".join(lines)
|
|
85
|
+
|
|
86
|
+
output_path = Path(output_path)
|
|
87
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
output_path.write_text(report, encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
return report
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collect_failures(results: list["TestResult"]) -> list[dict]:
|
|
94
|
+
"""Extract failed/error tests as structured dicts for issue creation."""
|
|
95
|
+
failures = []
|
|
96
|
+
for r in results:
|
|
97
|
+
if r.passed:
|
|
98
|
+
continue
|
|
99
|
+
entry = {
|
|
100
|
+
"name": r.name,
|
|
101
|
+
"method": r.request_method,
|
|
102
|
+
"url": r.request_url,
|
|
103
|
+
"status_code": r.status_code,
|
|
104
|
+
"duration_ms": r.duration_ms,
|
|
105
|
+
}
|
|
106
|
+
if r.is_error:
|
|
107
|
+
entry["type"] = "ERROR"
|
|
108
|
+
entry["detail"] = r.error
|
|
109
|
+
else:
|
|
110
|
+
entry["type"] = "FAIL"
|
|
111
|
+
entry["detail"] = "\n".join(f"- {f}" for f in r.failures)
|
|
112
|
+
if r.response_body:
|
|
113
|
+
entry["response_body"] = r.response_body[:500]
|
|
114
|
+
failures.append(entry)
|
|
115
|
+
return failures
|
auto_api_test/runner.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""HTTP request runner with variable context management."""
|
|
2
|
+
|
|
3
|
+
import json as json_module
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .assertions import assert_headers, assert_json, assert_status_code
|
|
12
|
+
from .parser import resolve_vars
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TestResult:
|
|
17
|
+
"""Result of a single test case execution."""
|
|
18
|
+
name: str
|
|
19
|
+
passed: bool
|
|
20
|
+
status_code: int = 0
|
|
21
|
+
duration_ms: float = 0.0
|
|
22
|
+
request_url: str = ""
|
|
23
|
+
request_method: str = ""
|
|
24
|
+
response_body: str = ""
|
|
25
|
+
failures: list[str] = field(default_factory=list)
|
|
26
|
+
error: str = ""
|
|
27
|
+
is_error: bool = False
|
|
28
|
+
response_json: Any = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Runner:
|
|
32
|
+
"""Executes API test cases sequentially with variable context."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: dict):
|
|
35
|
+
self.base_url = config.get("base_url", "").rstrip("/")
|
|
36
|
+
self.timeout = config.get("timeout", 30)
|
|
37
|
+
self.default_headers = config.get("headers", {})
|
|
38
|
+
self.session = requests.Session()
|
|
39
|
+
self.session.headers.update(self.default_headers)
|
|
40
|
+
self.variables: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
def run_tests(self, tests: list[dict]) -> list[TestResult]:
|
|
43
|
+
"""Run all test cases in order, maintaining variable context."""
|
|
44
|
+
results = []
|
|
45
|
+
for test in tests:
|
|
46
|
+
result = self._run_single(test)
|
|
47
|
+
results.append(result)
|
|
48
|
+
if not result.is_error:
|
|
49
|
+
self._extract_vars(test, result)
|
|
50
|
+
return results
|
|
51
|
+
|
|
52
|
+
def _run_single(self, test: dict) -> TestResult:
|
|
53
|
+
"""Execute a single test case."""
|
|
54
|
+
name = test["name"]
|
|
55
|
+
req_spec = test.get("request", {})
|
|
56
|
+
expect = test.get("expect", {})
|
|
57
|
+
|
|
58
|
+
req_spec = resolve_vars(req_spec, self.variables)
|
|
59
|
+
expect = resolve_vars(expect, self.variables)
|
|
60
|
+
|
|
61
|
+
method = req_spec.get("method", "GET").upper()
|
|
62
|
+
url = req_spec.get("url", "")
|
|
63
|
+
if self.base_url and not url.startswith(("http://", "https://")):
|
|
64
|
+
url = f"{self.base_url}{url}"
|
|
65
|
+
|
|
66
|
+
headers = req_spec.get("headers", {})
|
|
67
|
+
params = req_spec.get("params", {})
|
|
68
|
+
json_body = req_spec.get("json")
|
|
69
|
+
body = req_spec.get("body")
|
|
70
|
+
|
|
71
|
+
start = time.monotonic()
|
|
72
|
+
try:
|
|
73
|
+
resp = self.session.request(
|
|
74
|
+
method=method,
|
|
75
|
+
url=url,
|
|
76
|
+
headers=headers,
|
|
77
|
+
params=params,
|
|
78
|
+
json=json_body,
|
|
79
|
+
data=body,
|
|
80
|
+
timeout=self.timeout,
|
|
81
|
+
)
|
|
82
|
+
except requests.RequestException as e:
|
|
83
|
+
duration = (time.monotonic() - start) * 1000
|
|
84
|
+
return TestResult(
|
|
85
|
+
name=name,
|
|
86
|
+
passed=False,
|
|
87
|
+
request_url=url,
|
|
88
|
+
request_method=method,
|
|
89
|
+
duration_ms=round(duration, 2),
|
|
90
|
+
error=str(e),
|
|
91
|
+
is_error=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
duration = (time.monotonic() - start) * 1000
|
|
95
|
+
failures = []
|
|
96
|
+
|
|
97
|
+
if "status_code" in expect:
|
|
98
|
+
msg = assert_status_code(expect["status_code"], resp.status_code)
|
|
99
|
+
if msg:
|
|
100
|
+
failures.append(msg)
|
|
101
|
+
|
|
102
|
+
resp_json = None
|
|
103
|
+
if "json" in expect:
|
|
104
|
+
try:
|
|
105
|
+
resp_json = resp.json()
|
|
106
|
+
failures.extend(assert_json(expect["json"], resp_json))
|
|
107
|
+
except ValueError:
|
|
108
|
+
failures.append("Response body is not valid JSON")
|
|
109
|
+
|
|
110
|
+
if "headers" in expect:
|
|
111
|
+
failures.extend(assert_headers(expect["headers"], dict(resp.headers)))
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
resp_body = resp.text[:2000]
|
|
115
|
+
except Exception:
|
|
116
|
+
resp_body = ""
|
|
117
|
+
|
|
118
|
+
return TestResult(
|
|
119
|
+
name=name,
|
|
120
|
+
passed=len(failures) == 0,
|
|
121
|
+
status_code=resp.status_code,
|
|
122
|
+
duration_ms=round(duration, 2),
|
|
123
|
+
request_url=url,
|
|
124
|
+
request_method=method,
|
|
125
|
+
response_body=resp_body,
|
|
126
|
+
failures=failures,
|
|
127
|
+
response_json=resp_json,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _resolve_extract_path(self, data: Any, path: str) -> Any:
|
|
131
|
+
"""Resolve a dotted path to extract a value from JSON data."""
|
|
132
|
+
parts = path.split(".")
|
|
133
|
+
current = data
|
|
134
|
+
for part in parts:
|
|
135
|
+
if isinstance(current, dict) and part in current:
|
|
136
|
+
current = current[part]
|
|
137
|
+
elif isinstance(current, list) and part.isdigit():
|
|
138
|
+
idx = int(part)
|
|
139
|
+
if idx < len(current):
|
|
140
|
+
current = current[idx]
|
|
141
|
+
else:
|
|
142
|
+
return None
|
|
143
|
+
else:
|
|
144
|
+
return None
|
|
145
|
+
return current
|
|
146
|
+
|
|
147
|
+
def _extract_vars(self, test: dict, result: TestResult) -> None:
|
|
148
|
+
"""Extract variables from response for subsequent tests."""
|
|
149
|
+
extract = test.get("extract", {})
|
|
150
|
+
if not extract:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
resp_json = result.response_json
|
|
154
|
+
if resp_json is None:
|
|
155
|
+
try:
|
|
156
|
+
resp_json = json_module.loads(result.response_body)
|
|
157
|
+
except (json_module.JSONDecodeError, TypeError):
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
for var_name, json_path in extract.items():
|
|
161
|
+
json_path = resolve_vars(json_path, self.variables)
|
|
162
|
+
value = self._resolve_extract_path(resp_json, json_path)
|
|
163
|
+
if value is not None:
|
|
164
|
+
self.variables[var_name] = str(value)
|