api-test-toolkit 0.2.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.
@@ -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
+ )
api_test_kit/cli.py ADDED
@@ -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()
api_test_kit/client.py ADDED
@@ -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)
api_test_kit/config.py ADDED
@@ -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
+ ]
@@ -0,0 +1,63 @@
1
+ """ScenarioContext — E2E 步骤间共享的可变状态。
2
+
3
+ 每个步骤可以从自己的响应中提取值存入上下文,
4
+ 后续步骤再读取使用。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from jsonpath_ng import parse as parse_jsonpath
11
+
12
+ from api_test_kit.client import ApiResponse
13
+
14
+
15
+ class ScenarioContext:
16
+ """Dict-like context that passes data between E2E steps.
17
+
18
+ Usage::
19
+
20
+ ctx = ScenarioContext()
21
+ ctx.set("order_id", "ORD-123")
22
+ ctx.get("order_id") # -> "ORD-123"
23
+
24
+ # Extract from API response using JSONPath:
25
+ ctx.extract_from(resp, {"created_id": "$.data.id"})
26
+ ctx.get("created_id") # -> 42
27
+ """
28
+
29
+ def __init__(self) -> None:
30
+ self._store: dict[str, Any] = {}
31
+
32
+ def set(self, key: str, value: Any) -> None:
33
+ """Store a value in context."""
34
+ self._store[key] = value
35
+
36
+ def get(self, key: str, default: Any = None) -> Any:
37
+ """Retrieve a value from context (or *default* if missing)."""
38
+ return self._store.get(key, default)
39
+
40
+ def extract_from(self, resp: ApiResponse, mappings: dict[str, str]) -> None:
41
+ """Extract values from an API response into context.
42
+
43
+ ``mappings`` maps context key → JSONPath expression::
44
+
45
+ ctx.extract_from(resp, {"order_id": "$.data.id", "status": "$.data.status"})
46
+
47
+ Raises ``KeyError`` if any JSONPath produces no match.
48
+ """
49
+ for context_key, jsonpath in mappings.items():
50
+ matches = parse_jsonpath(jsonpath).find(resp.data)
51
+ if matches:
52
+ self._store[context_key] = matches[0].value
53
+ else:
54
+ raise KeyError(
55
+ f"JSONPath '{jsonpath}' found no match in response "
56
+ f"for context key '{context_key}'"
57
+ )
58
+
59
+ def __contains__(self, key: str) -> bool:
60
+ return key in self._store
61
+
62
+ def __repr__(self) -> str:
63
+ return f"ScenarioContext({self._store})"
@@ -0,0 +1,26 @@
1
+ """E2E 场景测试的便捷辅助函数。"""
2
+ from __future__ import annotations
3
+
4
+ from api_test_kit.e2e.context import ScenarioContext
5
+ from api_test_kit.e2e.scenario import Scenario, ScenarioReport
6
+
7
+
8
+ def run_chain(scenario: Scenario, ctx: ScenarioContext | None = None) -> ScenarioReport:
9
+ """Run a scenario — sugar for one-liner tests.
10
+
11
+ Usage::
12
+
13
+ def test_business_flow(client):
14
+ report = run_chain(Scenario(
15
+ "下单流程",
16
+ steps=[Step(name="创建", execute=...)],
17
+ ))
18
+ report.assert_all_passed()
19
+ """
20
+ return scenario.run(ctx)
21
+
22
+
23
+ def chain_summary(report: ScenarioReport) -> str:
24
+ """Return an inline one-line summary for pytest verbose output."""
25
+ status = "✅" if report.success else "❌"
26
+ return f"[E2E] {report.name}: {status} {report.passed}/{report.total} ({report.elapsed:.2f}s)"