api-test-toolkit 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.
- api_test_toolkit-0.2.0/PKG-INFO +21 -0
- api_test_toolkit-0.2.0/README.md +113 -0
- api_test_toolkit-0.2.0/pyproject.toml +36 -0
- api_test_toolkit-0.2.0/setup.cfg +4 -0
- api_test_toolkit-0.2.0/src/api_test_kit/__init__.py +18 -0
- api_test_toolkit-0.2.0/src/api_test_kit/assertions.py +128 -0
- api_test_toolkit-0.2.0/src/api_test_kit/cli.py +45 -0
- api_test_toolkit-0.2.0/src/api_test_kit/client.py +208 -0
- api_test_toolkit-0.2.0/src/api_test_kit/config.py +82 -0
- api_test_toolkit-0.2.0/src/api_test_kit/e2e/__init__.py +15 -0
- api_test_toolkit-0.2.0/src/api_test_kit/e2e/context.py +63 -0
- api_test_toolkit-0.2.0/src/api_test_kit/e2e/helpers.py +26 -0
- api_test_toolkit-0.2.0/src/api_test_kit/e2e/scenario.py +113 -0
- api_test_toolkit-0.2.0/src/api_test_kit/e2e/step.py +99 -0
- api_test_toolkit-0.2.0/src/api_test_kit/endpoints/__init__.py +44 -0
- api_test_toolkit-0.2.0/src/api_test_kit/endpoints/_base.py +44 -0
- api_test_toolkit-0.2.0/src/api_test_kit/logger.py +25 -0
- api_test_toolkit-0.2.0/src/api_test_kit/scaffold.py +186 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/AGENTS.md +45 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/README.md +31 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/pyproject.toml +21 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/pytest.ini +11 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/scripts/new-endpoint.sh +75 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/tests/__init__.py +1 -0
- api_test_toolkit-0.2.0/src/api_test_kit/templates/tests/conftest.py +44 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/PKG-INFO +21 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/SOURCES.txt +34 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/dependency_links.txt +1 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/entry_points.txt +2 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/requires.txt +17 -0
- api_test_toolkit-0.2.0/src/api_test_toolkit.egg-info/top_level.txt +1 -0
- api_test_toolkit-0.2.0/tests/test_assertions.py +107 -0
- api_test_toolkit-0.2.0/tests/test_client.py +130 -0
- api_test_toolkit-0.2.0/tests/test_e2e_context.py +45 -0
- api_test_toolkit-0.2.0/tests/test_e2e_scenario.py +104 -0
- api_test_toolkit-0.2.0/tests/test_e2e_step.py +69 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: api-test-toolkit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AI 友好的 API 自动化测试工具包
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: requests>=2.31
|
|
7
|
+
Requires-Dist: pydantic<3,>=2.5
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.1
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0
|
|
10
|
+
Requires-Dist: jsonpath-ng>=1.6
|
|
11
|
+
Requires-Dist: deepdiff>=7.0
|
|
12
|
+
Requires-Dist: typer<1,>=0.12
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-xdist>=3.5; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-html>=4.1; extra == "dev"
|
|
17
|
+
Requires-Dist: allure-pytest>=2.13; extra == "dev"
|
|
18
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
19
|
+
Requires-Dist: faker>=22.0; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.3; extra == "dev"
|
|
21
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# API Test Kit
|
|
2
|
+
|
|
3
|
+
基于 Python + pytest + requests 的 AI 友好型 API 自动化测试工具包。
|
|
4
|
+
|
|
5
|
+
专为 AI 助手(Copilot、Hermes、Claude)设计,让它们**秒懂工程约定**,快速生成一致、类型化、可维护的测试脚本。
|
|
6
|
+
|
|
7
|
+
## 快速开始
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
make install # 一次性安装
|
|
11
|
+
make test # 并行运行所有测试
|
|
12
|
+
make test-report # 生成 HTML 测试报告
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 项目结构
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
src/api_test_kit/ # 测试框架核心
|
|
19
|
+
config.py # 多环境配置(Pydantic)
|
|
20
|
+
client.py # HTTP 客户端(重试、日志、鉴权)
|
|
21
|
+
assertions.py # 可读性断言工具
|
|
22
|
+
logger.py # 日志配置
|
|
23
|
+
endpoints/ # API 端点模型(AI 生成)
|
|
24
|
+
_base.py # CRUD 基类
|
|
25
|
+
|
|
26
|
+
tests/ # 测试脚本
|
|
27
|
+
conftest.py # 全局 fixtures
|
|
28
|
+
helpers/ # 测试数据工厂
|
|
29
|
+
endpoints/ # 按端点的测试文件(AI 生成)
|
|
30
|
+
e2e/ # 业务链路 E2E 测试
|
|
31
|
+
|
|
32
|
+
data/fixtures/ # JSON/YAML 测试数据
|
|
33
|
+
scripts/
|
|
34
|
+
new-endpoint.sh # 一键生成新端点的脚手架脚本
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 新增 API 测试
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
./scripts/new-endpoint.sh products
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
然后在 `src/api_test_kit/endpoints/products.py` 实现端点模型,运行:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pytest tests/endpoints/test_products.py -v
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**AI 协助的测试生成流程:**
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
① 提供 curl / API 文档
|
|
53
|
+
② AI 试调接口,设计测试用例 → 展示给你 review
|
|
54
|
+
③ 你确认/补充业务场景
|
|
55
|
+
④ AI 写 endpoint 模型 + 测试
|
|
56
|
+
⑤ pytest -v 跑通
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
详细规范见 [AGENTS.md](./AGENTS.md)。
|
|
60
|
+
|
|
61
|
+
## 常用命令
|
|
62
|
+
|
|
63
|
+
| 命令 | 说明 |
|
|
64
|
+
|------|------|
|
|
65
|
+
| `make install` | 安装依赖 |
|
|
66
|
+
| `make test` | 并行运行所有测试 |
|
|
67
|
+
| `make test-report` | 生成 HTML 报告 |
|
|
68
|
+
| `make lint` | ruff 代码检查 |
|
|
69
|
+
| `make format` | 自动格式化代码 |
|
|
70
|
+
| `pytest -m smoke` | 只跑 smoke 测试 |
|
|
71
|
+
| `pytest -m e2e` | 只跑 E2E 链路测试 |
|
|
72
|
+
| `pytest tests/endpoints/test_xxx.py -v` | 单文件测试 |
|
|
73
|
+
|
|
74
|
+
## 核心概念
|
|
75
|
+
|
|
76
|
+
- **`HarnessConfig`** — 基于 Pydantic 的多环境配置(`.env` 文件)
|
|
77
|
+
- **`BaseClient`** — `requests.Session` 封装,支持重试、日志、自动鉴权
|
|
78
|
+
- **`ApiResponse(status, data, elapsed)`** — 类型化响应,不暴露原始 `requests.Response`
|
|
79
|
+
- **`BaseEndpoint`** — CRUD 模板:`list / get / create / update / delete`
|
|
80
|
+
- **`assert_*`** — `assert_status`、`assert_json_has`、`assert_json_match`、`assert_error`
|
|
81
|
+
- **`Scenario` / `Step`** — E2E 业务链路测试(详见 [AGENTS.md §10](./AGENTS.md#10-e2e-business-chain-tests))
|
|
82
|
+
|
|
83
|
+
## E2E 业务链路测试
|
|
84
|
+
|
|
85
|
+
除了单接口测试,工具包还支持**多步骤 E2E 场景**,数据在步骤间流转:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from api_test_kit.e2e import Scenario, Step, run_chain
|
|
89
|
+
|
|
90
|
+
scenario = Scenario("业务链路", steps=[
|
|
91
|
+
Step(name="步骤1", execute=..., extract={"key": "$.data.id"}, verify=...),
|
|
92
|
+
Step(name="步骤2", execute=lambda ctx: api.call(ctx.get("key")), verify=...),
|
|
93
|
+
])
|
|
94
|
+
report = run_chain(scenario)
|
|
95
|
+
report.assert_all_passed()
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `Step(name, execute, extract, verify)` — 业务链中的一个环节
|
|
99
|
+
- `ScenarioContext` — 跨步骤共享状态
|
|
100
|
+
- `ScenarioReport` — 每步的通过/失败汇总
|
|
101
|
+
- 测试类标记 `@pytest.mark.e2e`;用 `pytest -m e2e` 执行
|
|
102
|
+
|
|
103
|
+
## 配合 AI 使用
|
|
104
|
+
|
|
105
|
+
给 AI 助手一个 API 的 curl 或接口文档,即可自动生成完整测试:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
① 你提供 curl / API 文档
|
|
109
|
+
② AI 试调接口 → 设计测试用例 → 展示给你 review
|
|
110
|
+
③ 你确认 / 补充业务场景
|
|
111
|
+
④ AI 写 endpoint 模型 + 测试脚本
|
|
112
|
+
⑤ pytest -v 跑通
|
|
113
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta:__legacy__"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "api-test-toolkit"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "AI 友好的 API 自动化测试工具包"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
"pydantic>=2.5,<3",
|
|
13
|
+
"pydantic-settings>=2.1",
|
|
14
|
+
"python-dotenv>=1.0",
|
|
15
|
+
"jsonpath-ng>=1.6",
|
|
16
|
+
"deepdiff>=7.0",
|
|
17
|
+
"typer>=0.12,<1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
api-test-toolkit = "api_test_kit.cli:app"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.package-data]
|
|
24
|
+
api_test_kit = ["templates/**/*"]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=8.0",
|
|
29
|
+
"pytest-xdist>=3.5",
|
|
30
|
+
"pytest-html>=4.1",
|
|
31
|
+
"allure-pytest>=2.13",
|
|
32
|
+
"responses>=0.25",
|
|
33
|
+
"faker>=22.0",
|
|
34
|
+
"ruff>=0.3",
|
|
35
|
+
"mypy>=1.8",
|
|
36
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""API Test Kit — AI 友好的 API 自动化测试工具包。"""
|
|
2
|
+
from api_test_kit.e2e.context import ScenarioContext
|
|
3
|
+
from api_test_kit.e2e.step import Step, StepResult
|
|
4
|
+
from api_test_kit.e2e.scenario import Scenario, ScenarioReport
|
|
5
|
+
from api_test_kit.e2e.helpers import run_chain, chain_summary
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ScenarioContext",
|
|
11
|
+
"Step",
|
|
12
|
+
"StepResult",
|
|
13
|
+
"Scenario",
|
|
14
|
+
"ScenarioReport",
|
|
15
|
+
"run_chain",
|
|
16
|
+
"chain_summary",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""API 测试的断言辅助函数。
|
|
2
|
+
|
|
3
|
+
使用方法::
|
|
4
|
+
|
|
5
|
+
from api_test_kit.assertions import assert_status, assert_json_has, assert_json_match
|
|
6
|
+
|
|
7
|
+
resp = client.get("/users")
|
|
8
|
+
assert_status(resp, 200)
|
|
9
|
+
assert_json_has(resp, "$.data.items[*].id")
|
|
10
|
+
assert_json_match(resp, {"page": 1})
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from deepdiff import DeepDiff
|
|
17
|
+
from jsonpath_ng import parse as parse_jsonpath
|
|
18
|
+
|
|
19
|
+
from api_test_kit.client import ApiResponse
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def assert_status(resp: ApiResponse, expected: int = 200) -> None:
|
|
23
|
+
"""断言 HTTP 状态码完全匹配。"""
|
|
24
|
+
assert resp.status == expected, (
|
|
25
|
+
f"Expected status {expected}, got {resp.status}. Body: {resp.data}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def assert_ok(resp: ApiResponse) -> None:
|
|
30
|
+
"""断言 ``resp.ok`` 为 True(状态码在 2xx 范围内)。"""
|
|
31
|
+
assert resp.ok, f"Expected 2xx, got {resp.status}. Body: {resp.data}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assert_json_has(resp: ApiResponse, jsonpath: str) -> list[Any]:
|
|
35
|
+
"""断言 *jsonpath* 在响应体中至少匹配一次。
|
|
36
|
+
|
|
37
|
+
返回匹配的值以支持进一步的断言链式调用。
|
|
38
|
+
|
|
39
|
+
示例::
|
|
40
|
+
|
|
41
|
+
ids = assert_json_has(resp, "$.data.items[*].id")
|
|
42
|
+
assert len(ids) > 0
|
|
43
|
+
"""
|
|
44
|
+
matches = parse_jsonpath(jsonpath).find(resp.data)
|
|
45
|
+
assert len(matches) > 0, (
|
|
46
|
+
f"JSONPath ``{jsonpath}`` found no matches in response.\n"
|
|
47
|
+
f"Response body: {resp.data}"
|
|
48
|
+
)
|
|
49
|
+
return [m.value for m in matches]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def assert_json_not_has(resp: ApiResponse, jsonpath: str) -> None:
|
|
53
|
+
"""断言 *jsonpath* 在响应体中无匹配项。"""
|
|
54
|
+
matches = parse_jsonpath(jsonpath).find(resp.data)
|
|
55
|
+
assert len(matches) == 0, (
|
|
56
|
+
f"JSONPath ``{jsonpath}`` unexpectedly found: {[m.value for m in matches]}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def assert_json_match(
|
|
61
|
+
resp: ApiResponse,
|
|
62
|
+
expected: dict,
|
|
63
|
+
exclude_paths: list[str] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""断言响应体包含 *expected* 中的所有键/值(子集匹配)。
|
|
66
|
+
|
|
67
|
+
*exclude_paths*: 要忽略的根相对路径列表,例如 ``["created_at"]``。
|
|
68
|
+
|
|
69
|
+
示例::
|
|
70
|
+
|
|
71
|
+
assert_json_match(resp, {"total": 10, "page": 1})
|
|
72
|
+
"""
|
|
73
|
+
body = resp.data if isinstance(resp.data, dict) else {"body": resp.data}
|
|
74
|
+
diff = DeepDiff(
|
|
75
|
+
expected,
|
|
76
|
+
body,
|
|
77
|
+
verbose_level=2,
|
|
78
|
+
exclude_paths=[f"root['{p}']" for p in (exclude_paths or [])],
|
|
79
|
+
)
|
|
80
|
+
# Subset check: body can have extra keys — only fail on mismatches/absences
|
|
81
|
+
diff.pop("dictionary_item_added", None)
|
|
82
|
+
assert not diff, (
|
|
83
|
+
f"Response body does not match expected subset.\n"
|
|
84
|
+
f"Differences: {diff}\n"
|
|
85
|
+
f"Expected subset: {expected}\n"
|
|
86
|
+
f"Actual: {resp.data}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def assert_paginated(resp: ApiResponse) -> dict:
|
|
91
|
+
"""断言响应具有标准分页结构并返回分页元数据。
|
|
92
|
+
|
|
93
|
+
检查 ``page``、``size``、``total``、``items`` 键是否存在。
|
|
94
|
+
"""
|
|
95
|
+
data = resp.data if isinstance(resp.data, dict) else {}
|
|
96
|
+
for key in ("page", "size", "total", "items"):
|
|
97
|
+
assert key in data, f"Missing pagination key ``{key}`` in response: {data}"
|
|
98
|
+
assert isinstance(data["items"], list), (
|
|
99
|
+
f"``items`` must be a list, got {type(data['items'])}"
|
|
100
|
+
)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def assert_error(
|
|
105
|
+
resp: ApiResponse,
|
|
106
|
+
expected_code: int = 400,
|
|
107
|
+
message_hint: str | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""断言错误响应,可选的消息提示检查。
|
|
110
|
+
|
|
111
|
+
处理常见的错误结构::
|
|
112
|
+
|
|
113
|
+
{"code": "VALIDATION_ERROR", "message": "...", "details": [...]}
|
|
114
|
+
{"error": {"code": "NOT_FOUND", "message": "..."}}
|
|
115
|
+
"""
|
|
116
|
+
assert resp.status == expected_code, (
|
|
117
|
+
f"Expected error status {expected_code}, got {resp.status}"
|
|
118
|
+
)
|
|
119
|
+
body = resp.data if isinstance(resp.data, dict) else {}
|
|
120
|
+
if message_hint:
|
|
121
|
+
msg = (
|
|
122
|
+
body.get("message", "")
|
|
123
|
+
or (body.get("error", {}) or {}).get("message", "")
|
|
124
|
+
or str(body)
|
|
125
|
+
)
|
|
126
|
+
assert message_hint.lower() in msg.lower(), (
|
|
127
|
+
f"Expected error message containing ``{message_hint}``, got: {msg}"
|
|
128
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CLI 入口 — project scaffolding commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from api_test_kit import __version__
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="api-test-toolkit",
|
|
10
|
+
help="AI 友好的 API 自动化测试工具包",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def init(
|
|
16
|
+
project_dir: str = typer.Argument(..., help="新项目目录名"),
|
|
17
|
+
base_url: str | None = typer.Option(None, "--base-url", "-b", help="API 基础地址"),
|
|
18
|
+
api_prefix: str | None = typer.Option(None, "--api-prefix", "-p", help="API 前缀"),
|
|
19
|
+
auth_type: str | None = typer.Option(None, "--auth-type", "-a", help="鉴权方式"),
|
|
20
|
+
env_name: str | None = typer.Option(None, "--env", "-e", help="环境名"),
|
|
21
|
+
yes: bool = typer.Option(
|
|
22
|
+
False, "--yes", "-y", help="非交互模式(使用默认值,不提问)"
|
|
23
|
+
),
|
|
24
|
+
) -> None:
|
|
25
|
+
"""创建一个新的 API 自动化测试项目。"""
|
|
26
|
+
from api_test_kit.scaffold import create_project
|
|
27
|
+
|
|
28
|
+
create_project(
|
|
29
|
+
project_dir=project_dir,
|
|
30
|
+
base_url=base_url,
|
|
31
|
+
api_prefix=api_prefix,
|
|
32
|
+
auth_type=auth_type,
|
|
33
|
+
env_name=env_name,
|
|
34
|
+
interactive=not yes,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def version() -> None:
|
|
40
|
+
"""显示版本号。"""
|
|
41
|
+
typer.echo(f"api-test-kit v{__version__}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
app()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""支持重试、日志、认证和类型化响应的基础 HTTP 客户端。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests.adapters import HTTPAdapter, Retry
|
|
11
|
+
|
|
12
|
+
from api_test_kit.config import HarnessConfig
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── 类型化响应 ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ApiResponse:
|
|
22
|
+
"""标准 API 响应——绝不向测试暴露原始的 ``requests.Response``。"""
|
|
23
|
+
|
|
24
|
+
status: int
|
|
25
|
+
data: Any # 解析后的 JSON 正文,或原始文本回退
|
|
26
|
+
elapsed: float # 实际运行秒数
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
ok: bool = True
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_requests(cls, resp: requests.Response) -> ApiResponse:
|
|
32
|
+
"""从 ``requests.Response`` 构建——尽可能解析 JSON。"""
|
|
33
|
+
try:
|
|
34
|
+
data = resp.json()
|
|
35
|
+
except ValueError:
|
|
36
|
+
data = resp.text
|
|
37
|
+
return cls(
|
|
38
|
+
status=resp.status_code,
|
|
39
|
+
data=data,
|
|
40
|
+
elapsed=resp.elapsed.total_seconds(),
|
|
41
|
+
headers=dict(resp.headers),
|
|
42
|
+
ok=200 <= resp.status_code < 300,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __bool__(self) -> bool:
|
|
46
|
+
return self.ok
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Token 提供者 ──────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenProvider:
|
|
53
|
+
"""抽象 Token 提供者——为每种认证方式重写。"""
|
|
54
|
+
|
|
55
|
+
def get_token(self) -> str:
|
|
56
|
+
"""返回当前 token。"""
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
def refresh(self) -> str:
|
|
60
|
+
"""收到 401 时调用。返回一个新的 token。"""
|
|
61
|
+
return self.get_token()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BearerTokenProvider(TokenProvider):
|
|
65
|
+
"""基于固定值(如环境变量或配置)的简单 bearer token。"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, token: str) -> None:
|
|
68
|
+
self._token = token
|
|
69
|
+
|
|
70
|
+
def get_token(self) -> str:
|
|
71
|
+
return self._token
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── 基础客户端 ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BaseClient:
|
|
78
|
+
"""封装 ``requests.Session``,支持重试、日志和认证。
|
|
79
|
+
|
|
80
|
+
使用方法::
|
|
81
|
+
|
|
82
|
+
config = HarnessConfig.from_env("staging")
|
|
83
|
+
client = BaseClient(config)
|
|
84
|
+
client.authenticate(BearerTokenProvider("xxx"))
|
|
85
|
+
resp: ApiResponse = client.get("/api/v1/users")
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, config: HarnessConfig) -> None:
|
|
89
|
+
self.config = config
|
|
90
|
+
self.session = requests.Session()
|
|
91
|
+
self._setup_retry()
|
|
92
|
+
self._token_provider: TokenProvider | None = None
|
|
93
|
+
self._token: str | None = None
|
|
94
|
+
|
|
95
|
+
# ── 认证 ────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
def authenticate(self, provider: TokenProvider) -> None:
|
|
98
|
+
"""设置认证提供者。Token 在首次请求时惰性获取。"""
|
|
99
|
+
self._token_provider = provider
|
|
100
|
+
self._token = None
|
|
101
|
+
|
|
102
|
+
def _ensure_token(self) -> None:
|
|
103
|
+
if self._token is None and self._token_provider:
|
|
104
|
+
self._token = self._token_provider.get_token()
|
|
105
|
+
|
|
106
|
+
def _refresh_token(self) -> None:
|
|
107
|
+
if self._token_provider:
|
|
108
|
+
self._token = self._token_provider.refresh()
|
|
109
|
+
else:
|
|
110
|
+
self._token = None
|
|
111
|
+
|
|
112
|
+
def _inject_auth(self, headers: dict[str, str]) -> dict[str, str]:
|
|
113
|
+
auth_type = self.config.auth_type
|
|
114
|
+
header_name = self.config.auth_header_name
|
|
115
|
+
if auth_type == "bearer" and self._token:
|
|
116
|
+
headers[header_name] = f"Bearer {self._token}"
|
|
117
|
+
elif auth_type == "api_key" and self._token:
|
|
118
|
+
headers[header_name] = self._token
|
|
119
|
+
return headers
|
|
120
|
+
|
|
121
|
+
# ── HTTP 方法 ────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def get(self, path: str, **kwargs: Any) -> ApiResponse:
|
|
124
|
+
return self._request("GET", path, **kwargs)
|
|
125
|
+
|
|
126
|
+
def post(self, path: str, **kwargs: Any) -> ApiResponse:
|
|
127
|
+
return self._request("POST", path, **kwargs)
|
|
128
|
+
|
|
129
|
+
def put(self, path: str, **kwargs: Any) -> ApiResponse:
|
|
130
|
+
return self._request("PUT", path, **kwargs)
|
|
131
|
+
|
|
132
|
+
def patch(self, path: str, **kwargs: Any) -> ApiResponse:
|
|
133
|
+
return self._request("PATCH", path, **kwargs)
|
|
134
|
+
|
|
135
|
+
def delete(self, path: str, **kwargs: Any) -> ApiResponse:
|
|
136
|
+
return self._request("DELETE", path, **kwargs)
|
|
137
|
+
|
|
138
|
+
# ── 内部方法 ────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> ApiResponse:
|
|
141
|
+
url = self._resolve_url(path)
|
|
142
|
+
self._ensure_token()
|
|
143
|
+
headers = kwargs.pop("headers", {})
|
|
144
|
+
headers = self._inject_auth(headers)
|
|
145
|
+
|
|
146
|
+
for attempt in range(self.config.max_retries + 1):
|
|
147
|
+
start = time.perf_counter()
|
|
148
|
+
try:
|
|
149
|
+
if self.config.log_requests:
|
|
150
|
+
logger.debug(
|
|
151
|
+
"[REQ] %s %s | body=%s",
|
|
152
|
+
method,
|
|
153
|
+
url,
|
|
154
|
+
kwargs.get("json", kwargs.get("data", "")),
|
|
155
|
+
)
|
|
156
|
+
resp = self.session.request(
|
|
157
|
+
method, url, headers=headers, timeout=self.config.timeout, **kwargs
|
|
158
|
+
)
|
|
159
|
+
elapsed = time.perf_counter() - start
|
|
160
|
+
api_resp = ApiResponse.from_requests(resp)
|
|
161
|
+
|
|
162
|
+
if self.config.log_responses:
|
|
163
|
+
logger.debug(
|
|
164
|
+
"[RES] %s %s → %d (%.3fs)", method, url, resp.status_code, elapsed
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Auto-refresh token on 401 and retry
|
|
168
|
+
if resp.status_code == 401 and self._token_provider and attempt < self.config.max_retries:
|
|
169
|
+
logger.info("[AUTH] 401 received — refreshing token (attempt %d)", attempt + 1)
|
|
170
|
+
self._refresh_token()
|
|
171
|
+
headers = self._inject_auth(headers)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
return api_resp
|
|
175
|
+
|
|
176
|
+
except requests.RequestException as e:
|
|
177
|
+
elapsed = time.perf_counter() - start
|
|
178
|
+
logger.warning("[ERR] %s %s failed after %.3fs: %s", method, url, elapsed, e)
|
|
179
|
+
if attempt < self.config.max_retries:
|
|
180
|
+
backoff = self.config.retry_backoff * (2**attempt)
|
|
181
|
+
logger.info(
|
|
182
|
+
"[RETRY] waiting %.1fs before retry %d/%d",
|
|
183
|
+
backoff,
|
|
184
|
+
attempt + 1,
|
|
185
|
+
self.config.max_retries,
|
|
186
|
+
)
|
|
187
|
+
time.sleep(backoff)
|
|
188
|
+
continue
|
|
189
|
+
return ApiResponse(status=0, data=str(e), elapsed=elapsed, ok=False)
|
|
190
|
+
|
|
191
|
+
raise RuntimeError("Unreachable — all retries exhausted")
|
|
192
|
+
|
|
193
|
+
def _resolve_url(self, path: str) -> str:
|
|
194
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
195
|
+
return path
|
|
196
|
+
return f"{self.config.api_url}{path}"
|
|
197
|
+
|
|
198
|
+
def _setup_retry(self) -> None:
|
|
199
|
+
"""挂载适配器,为 DNS / 连接错误提供连接级重试。"""
|
|
200
|
+
retry_strategy = Retry(
|
|
201
|
+
total=self.config.max_retries,
|
|
202
|
+
backoff_factor=self.config.retry_backoff * 0.5,
|
|
203
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
204
|
+
allowed_methods=["GET", "HEAD", "OPTIONS"],
|
|
205
|
+
)
|
|
206
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
207
|
+
self.session.mount("http://", adapter)
|
|
208
|
+
self.session.mount("https://", adapter)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""通过 Pydantic Settings 实现的多环境配置。
|
|
2
|
+
|
|
3
|
+
使用方法::
|
|
4
|
+
|
|
5
|
+
config = HarnessConfig.from_env("staging")
|
|
6
|
+
config.base_url # -> "https://staging-api.example.com"
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HarnessConfig(BaseSettings):
|
|
18
|
+
"""从环境文件或环境变量加载的应用配置。
|
|
19
|
+
|
|
20
|
+
优先级:环境变量 > 环境文件 > 默认值。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = SettingsConfigDict(
|
|
24
|
+
env_file=".env",
|
|
25
|
+
env_file_encoding="utf-8",
|
|
26
|
+
extra="ignore",
|
|
27
|
+
frozen=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# --- 核心 ---
|
|
31
|
+
env_name: str = Field(default="dev", description="Environment name (dev/staging/prod)")
|
|
32
|
+
base_url: str = Field(default="http://localhost:8000", description="API base URL")
|
|
33
|
+
api_prefix: str = Field(default="/api/v1", description="API version prefix")
|
|
34
|
+
|
|
35
|
+
# --- 认证 ---
|
|
36
|
+
auth_type: Literal["none", "bearer", "api_key", "basic", "oauth2"] = Field(
|
|
37
|
+
default="bearer",
|
|
38
|
+
)
|
|
39
|
+
auth_header_name: str = Field(
|
|
40
|
+
default="authorization",
|
|
41
|
+
description="HTTP header name for the auth token "
|
|
42
|
+
"(default: 'authorization' for bearer, 'x-api-key' for api_key)",
|
|
43
|
+
)
|
|
44
|
+
auth_token_url: str | None = Field(default=None, description="Token endpoint for OAuth2")
|
|
45
|
+
client_id: str | None = Field(default=None)
|
|
46
|
+
client_secret: str | None = Field(default=None, description="Also used as bearer token value")
|
|
47
|
+
|
|
48
|
+
# --- HTTP 客户端 ---
|
|
49
|
+
timeout: int = Field(default=30, ge=1, le=300)
|
|
50
|
+
max_retries: int = Field(default=3, ge=0, le=10)
|
|
51
|
+
retry_backoff: float = Field(default=1.0, ge=0.1)
|
|
52
|
+
|
|
53
|
+
# --- 日志 ---
|
|
54
|
+
log_level: str = Field(default="INFO", pattern=r"^(DEBUG|INFO|WARNING|ERROR)$")
|
|
55
|
+
log_requests: bool = Field(default=True)
|
|
56
|
+
log_responses: bool = Field(default=True)
|
|
57
|
+
|
|
58
|
+
# --- 派生 ---
|
|
59
|
+
@property
|
|
60
|
+
def api_url(self) -> str:
|
|
61
|
+
"""完整的 API 基础 URL(base_url + api_prefix)。"""
|
|
62
|
+
return f"{self.base_url}{self.api_prefix}"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_env(cls, env_name: str = "dev") -> HarnessConfig:
|
|
66
|
+
"""加载指定环境的配置。
|
|
67
|
+
|
|
68
|
+
查找项目根目录下的 ``config.{env_name}.env``,
|
|
69
|
+
如果不存在则回退到 ``.env``。
|
|
70
|
+
"""
|
|
71
|
+
project_root = cls._find_project_root()
|
|
72
|
+
env_file = project_root / f"config.{env_name}.env"
|
|
73
|
+
return cls(_env_file=str(env_file) if env_file.exists() else None)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _find_project_root() -> Path:
|
|
77
|
+
"""从当前工作目录向上查找项目根目录(包含 pyproject.toml 的目录)。"""
|
|
78
|
+
start = Path.cwd()
|
|
79
|
+
for parent in [start] + list(start.parents):
|
|
80
|
+
if (parent / "pyproject.toml").exists():
|
|
81
|
+
return parent
|
|
82
|
+
return start
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""E2E 业务链路测试 — Scenario、Step、Context。"""
|
|
2
|
+
from api_test_kit.e2e.context import ScenarioContext
|
|
3
|
+
from api_test_kit.e2e.step import Step, StepResult
|
|
4
|
+
from api_test_kit.e2e.scenario import Scenario, ScenarioReport
|
|
5
|
+
from api_test_kit.e2e.helpers import run_chain, chain_summary
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ScenarioContext",
|
|
9
|
+
"Step",
|
|
10
|
+
"StepResult",
|
|
11
|
+
"Scenario",
|
|
12
|
+
"ScenarioReport",
|
|
13
|
+
"run_chain",
|
|
14
|
+
"chain_summary",
|
|
15
|
+
]
|