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,113 @@
1
+ """Scenario — 编排一组 E2E 步骤。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time as time_module
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal
8
+
9
+ from api_test_kit.e2e.context import ScenarioContext
10
+ from api_test_kit.e2e.step import Step, StepResult
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class ScenarioReport:
17
+ """Report after running a full ``Scenario``."""
18
+ name: str
19
+ step_results: list[StepResult] = field(default_factory=list)
20
+ total: int = 0
21
+ passed: int = 0
22
+ failed: int = 0
23
+ elapsed: float = 0.0
24
+
25
+ @property
26
+ def success(self) -> bool:
27
+ """True if every step passed."""
28
+ return self.failed == 0
29
+
30
+ def summary(self) -> str:
31
+ """Multi-line human-readable summary."""
32
+ status = "✅ PASS" if self.success else "❌ FAIL"
33
+ lines = [
34
+ f"[E2E] Scenario: {self.name}",
35
+ f" Status: {status}",
36
+ f" Steps: {self.passed}/{self.total} passed ({self.elapsed:.2f}s)",
37
+ ]
38
+ for r in self.step_results:
39
+ mark = "✅" if r.passed else "❌"
40
+ lines.append(f" {mark} {r.step_name} ({r.status}, {r.elapsed:.3f}s)")
41
+ if not r.passed and r.error:
42
+ lines.append(f" ↳ {r.error}")
43
+ return "\n".join(lines)
44
+
45
+ def assert_all_passed(self) -> None:
46
+ """Raise ``AssertionError`` listing all failures, if any."""
47
+ if self.failed > 0:
48
+ detail = "\n".join(
49
+ f" ❌ {r.step_name}: {r.error}"
50
+ for r in self.step_results
51
+ if not r.passed
52
+ )
53
+ raise AssertionError(
54
+ f"Scenario '{self.name}' failed: {self.failed}/{self.total} steps failed.\n"
55
+ f"{detail}"
56
+ )
57
+
58
+
59
+ class Scenario:
60
+ """A business chain composed of sequential ``Step`` objects.
61
+
62
+ Usage::
63
+
64
+ scenario = Scenario(
65
+ "下单流程",
66
+ steps=[
67
+ Step(name="登录", execute=...),
68
+ Step(name="创建订单", execute=...),
69
+ Step(name="验证订单状态", execute=...),
70
+ ],
71
+ )
72
+ report = scenario.run()
73
+ report.assert_all_passed()
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ name: str,
79
+ steps: list[Step],
80
+ fail_fast: bool = True,
81
+ ) -> None:
82
+ self.name = name
83
+ self.steps = steps
84
+ self.fail_fast = fail_fast
85
+
86
+ def run(self, ctx: ScenarioContext | None = None) -> ScenarioReport:
87
+ """Execute all steps in order.
88
+
89
+ If ``fail_fast=True`` (default), stops at the first failed step.
90
+ Returns a ``ScenarioReport`` with per-step results.
91
+ """
92
+ context = ctx or ScenarioContext()
93
+ report = ScenarioReport(name=self.name, total=len(self.steps))
94
+ start = time_module.perf_counter()
95
+
96
+ for step in self.steps:
97
+ result = step.run(context)
98
+ report.step_results.append(result)
99
+ if result.passed:
100
+ report.passed += 1
101
+ else:
102
+ report.failed += 1
103
+ if self.fail_fast:
104
+ logger.info(
105
+ "[E2E] Scenario '%s' stopped at failed step '%s'",
106
+ self.name,
107
+ step.name,
108
+ )
109
+ break
110
+
111
+ report.elapsed = time_module.perf_counter() - start
112
+ logger.info("\n" + report.summary())
113
+ return report
@@ -0,0 +1,99 @@
1
+ """Step — 业务链路场景中的可组合单元。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable
8
+
9
+ from api_test_kit.client import ApiResponse
10
+ from api_test_kit.e2e.context import ScenarioContext
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class StepResult:
17
+ """Result of executing a single Step."""
18
+ step_name: str
19
+ passed: bool
20
+ status: int
21
+ elapsed: float
22
+ data: Any = None
23
+ error: str | None = None
24
+
25
+
26
+ class Step:
27
+ """One step in an E2E scenario.
28
+
29
+ Usage::
30
+
31
+ Step(
32
+ name="创建订单",
33
+ execute=lambda ctx: OrdersEndpoint(client).create(
34
+ product_id=ctx.get("product_id"), quantity=2
35
+ ),
36
+ extract={"order_id": "$.data.id"},
37
+ verify=lambda resp: assert_status(resp, 201),
38
+ )
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ name: str,
44
+ execute: Callable[[ScenarioContext], ApiResponse],
45
+ extract: dict[str, str] | None = None,
46
+ verify: Callable[[ApiResponse], None] | None = None,
47
+ ) -> None:
48
+ self.name = name
49
+ self.execute = execute
50
+ self.extract = extract or {}
51
+ self.verify = verify
52
+
53
+ def run(self, ctx: ScenarioContext) -> StepResult:
54
+ """Execute this step, run verification, extract data.
55
+
56
+ Returns a ``StepResult`` — does NOT raise on assertion failure.
57
+ """
58
+ start = time.perf_counter()
59
+ try:
60
+ logger.info("[E2E] Step: %s", self.name)
61
+ resp = self.execute(ctx)
62
+ elapsed = time.perf_counter() - start
63
+
64
+ # Run verification (if provided)
65
+ if self.verify:
66
+ self.verify(resp)
67
+
68
+ # Extract data for subsequent steps
69
+ ctx.extract_from(resp, self.extract)
70
+
71
+ logger.info("[E2E] Step '%s' → %d (%.3fs)", self.name, resp.status, elapsed)
72
+ return StepResult(
73
+ step_name=self.name,
74
+ passed=True,
75
+ status=resp.status,
76
+ elapsed=elapsed,
77
+ data=resp.data,
78
+ )
79
+
80
+ except AssertionError as e:
81
+ elapsed = time.perf_counter() - start
82
+ logger.warning("[E2E] Step '%s' FAILED: %s", self.name, e)
83
+ return StepResult(
84
+ step_name=self.name,
85
+ passed=False,
86
+ status=resp.status if "resp" in dir() else 0,
87
+ elapsed=elapsed,
88
+ error=str(e),
89
+ )
90
+ except Exception as e:
91
+ elapsed = time.perf_counter() - start
92
+ logger.error("[E2E] Step '%s' ERROR: %s", self.name, e)
93
+ return StepResult(
94
+ step_name=self.name,
95
+ passed=False,
96
+ status=0,
97
+ elapsed=elapsed,
98
+ error=f"{type(e).__name__}: {e}",
99
+ )
@@ -0,0 +1,44 @@
1
+ """基础端点类 — API 资源的 CRUD 模板。
2
+
3
+ 每个 API 资源对应一个子类。"""
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from api_test_kit.client import BaseClient, ApiResponse
9
+
10
+
11
+ class BaseEndpoint:
12
+ """Extend this class for each API resource.
13
+
14
+ Usage::
15
+
16
+ class UserEndpoint(BaseEndpoint):
17
+ path = "/users"
18
+
19
+ def activate(self, user_id: int) -> ApiResponse:
20
+ return self.client.post(f"{self.path}/{user_id}/activate")
21
+
22
+ If your API does not follow standard CRUD, override
23
+ ``list``, ``get``, ``create``, ``update``, ``delete`` as needed.
24
+ """
25
+
26
+ path: str = ""
27
+
28
+ def __init__(self, client: BaseClient) -> None:
29
+ self.client = client
30
+
31
+ def list(self, **params: Any) -> ApiResponse:
32
+ return self.client.get(self.path, params=params)
33
+
34
+ def get(self, id_: str | int, **params: Any) -> ApiResponse:
35
+ return self.client.get(f"{self.path}/{id_}", params=params)
36
+
37
+ def create(self, **payload: Any) -> ApiResponse:
38
+ return self.client.post(self.path, json=payload)
39
+
40
+ def update(self, id_: str | int, **payload: Any) -> ApiResponse:
41
+ return self.client.put(f"{self.path}/{id_}", json=payload)
42
+
43
+ def delete(self, id_: str | int) -> ApiResponse:
44
+ return self.client.delete(f"{self.path}/{id_}")
@@ -0,0 +1,44 @@
1
+ """基础端点类 —— API 资源的 CRUD 模板。
2
+
3
+ 每个 API 资源对应一个子类。"""
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ from api_test_kit.client import BaseClient, ApiResponse
9
+
10
+
11
+ class BaseEndpoint:
12
+ """为每个 API 资源扩展此类。
13
+
14
+ 使用方法::
15
+
16
+ class UserEndpoint(BaseEndpoint):
17
+ path = "/users"
18
+
19
+ def activate(self, user_id: int) -> ApiResponse:
20
+ return self.client.post(f"{self.path}/{user_id}/activate")
21
+
22
+ 如果你的 API 不遵循标准 CRUD,按需覆盖
23
+ ``list``、``get``、``create``、``update``、``delete`` 方法。
24
+ """
25
+
26
+ path: str = ""
27
+
28
+ def __init__(self, client: BaseClient) -> None:
29
+ self.client = client
30
+
31
+ def list(self, **params: Any) -> ApiResponse:
32
+ return self.client.get(self.path, params=params)
33
+
34
+ def get(self, id_: str | int, **params: Any) -> ApiResponse:
35
+ return self.client.get(f"{self.path}/{id_}", params=params)
36
+
37
+ def create(self, **payload: Any) -> ApiResponse:
38
+ return self.client.post(self.path, json=payload)
39
+
40
+ def update(self, id_: str | int, **payload: Any) -> ApiResponse:
41
+ return self.client.put(f"{self.path}/{id_}", json=payload)
42
+
43
+ def delete(self, id_: str | int) -> ApiResponse:
44
+ return self.client.delete(f"{self.path}/{id_}")
api_test_kit/logger.py ADDED
@@ -0,0 +1,25 @@
1
+ """测试框架的日志设置。"""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import sys
6
+
7
+
8
+ def setup_logging(level: str = "INFO") -> None:
9
+ """配置测试框架的根日志。
10
+
11
+ 在测试会话开始时调用一次(通常在 ``conftest.py`` 中)。
12
+ """
13
+ logger = logging.getLogger("api_test_kit")
14
+ logger.setLevel(getattr(logging, level.upper(), logging.INFO))
15
+
16
+ if not logger.handlers:
17
+ handler = logging.StreamHandler(sys.stdout)
18
+ handler.setLevel(logging.DEBUG)
19
+ fmt = logging.Formatter(
20
+ "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
21
+ datefmt="%H:%M:%S",
22
+ )
23
+ handler.setFormatter(fmt)
24
+ logger.addHandler(handler)
25
+ logger.propagate = False
@@ -0,0 +1,186 @@
1
+ """项目脚手架 — 生成新项目的目录结构和文件。"""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import typer
10
+
11
+ from api_test_kit import __version__
12
+
13
+ # ── 交互式问题 ──────────────────────────────────────────────
14
+
15
+ QUESTIONS: list[dict[str, Any]] = [
16
+ {
17
+ "key": "base_url",
18
+ "prompt": "API 基础地址",
19
+ "default": "http://localhost:8000",
20
+ },
21
+ {
22
+ "key": "api_prefix",
23
+ "prompt": "API 前缀",
24
+ "default": "/api/v1",
25
+ },
26
+ {
27
+ "key": "auth_type",
28
+ "prompt": "鉴权方式",
29
+ "default": "bearer",
30
+ "choices": ["bearer", "api_key", "none"],
31
+ },
32
+ {
33
+ "key": "env_name",
34
+ "prompt": "环境名",
35
+ "default": "staging",
36
+ },
37
+ ]
38
+
39
+ # ── 模板文件清单 ────────────────────────────────────────────
40
+
41
+ TEMPLATE_FILES: list[str] = [
42
+ "pyproject.toml",
43
+ "README.md",
44
+ "AGENTS.md",
45
+ "pytest.ini",
46
+ "tests/__init__.py",
47
+ "tests/conftest.py",
48
+ "scripts/new-endpoint.sh",
49
+ ]
50
+
51
+ EMPTY_DIRS: list[str] = [
52
+ "tests/endpoints",
53
+ "reports",
54
+ ]
55
+
56
+ # ── 变量替换 ────────────────────────────────────────────────
57
+
58
+ def _resolve_template_dir() -> Path:
59
+ """返回模板文件目录(与 scaffold.py 同级的 templates/)。"""
60
+ return Path(__file__).resolve().parent / "templates"
61
+
62
+
63
+ def _ask_questions(interactive: bool) -> dict[str, Any]:
64
+ """收集用户配置(交互式或默认值)。"""
65
+ config: dict[str, Any] = {}
66
+ for q in QUESTIONS:
67
+ if interactive:
68
+ if "choices" in q:
69
+ val = typer.prompt(
70
+ q["prompt"],
71
+ default=q["default"],
72
+ )
73
+ # 验证选择
74
+ if val not in q["choices"]:
75
+ typer.echo(f" 有效选项: {', '.join(q['choices'])}")
76
+ val = q["default"]
77
+ else:
78
+ val = typer.prompt(q["prompt"], default=q["default"])
79
+ config[q["key"]] = val
80
+ else:
81
+ config[q["key"]] = q["default"]
82
+ return config
83
+
84
+
85
+ def _render_template(src: Path, dst: Path, vars: dict[str, str]) -> None:
86
+ """读取模板文件,替换变量,写入目标路径。"""
87
+ content = src.read_text(encoding="utf-8")
88
+ for key, val in vars.items():
89
+ content = content.replace("{{ " + key + " }}", str(val))
90
+ dst.write_text(content, encoding="utf-8")
91
+
92
+
93
+ # ── 主入口 ──────────────────────────────────────────────────
94
+
95
+ def create_project(
96
+ project_dir: str,
97
+ base_url: str | None = None,
98
+ api_prefix: str | None = None,
99
+ auth_type: str | None = None,
100
+ env_name: str | None = None,
101
+ interactive: bool = True,
102
+ ) -> None:
103
+ """创建新 API 自动化测试项目。
104
+
105
+ Args:
106
+ project_dir: 目标目录名(相对 CWD 或绝对路径)。
107
+ base_url: API 基础地址。
108
+ api_prefix: API 前缀。
109
+ auth_type: 鉴权方式(bearer / api_key / none)。
110
+ env_name: 环境名。
111
+ interactive: 是否交互式提问。
112
+ """
113
+ target = Path.cwd() / project_dir
114
+
115
+ # 检查目标目录
116
+ if target.exists():
117
+ typer.confirm(f"目录 {target} 已存在,覆盖?", abort=True)
118
+ shutil.rmtree(target)
119
+
120
+ # 收集配置
121
+ config = _ask_questions(interactive)
122
+ if base_url is not None:
123
+ config["base_url"] = base_url
124
+ if api_prefix is not None:
125
+ config["api_prefix"] = api_prefix
126
+ if auth_type is not None:
127
+ config["auth_type"] = auth_type
128
+ if env_name is not None:
129
+ config["env_name"] = env_name
130
+
131
+ # 派生变量
132
+ package_name = project_dir.replace("-", "_").replace(" ", "_").lower()
133
+ config["project_name"] = project_dir
134
+ config["package_name"] = package_name
135
+ config["api_test_kit_version"] = __version__
136
+
137
+ # 创建目录
138
+ template_dir = _resolve_template_dir()
139
+
140
+ # 复制模板文件
141
+ for rel in TEMPLATE_FILES:
142
+ src = template_dir / rel
143
+ dst = target / rel
144
+ dst.parent.mkdir(parents=True, exist_ok=True)
145
+ _render_template(src, dst, config)
146
+ # 如果是 shell 脚本,保留可执行权限
147
+ if src.suffix == ".sh":
148
+ dst.chmod(dst.stat().st_mode | 0o111)
149
+
150
+ # 创建空目录
151
+ for rel in EMPTY_DIRS:
152
+ (target / rel).mkdir(parents=True, exist_ok=True)
153
+
154
+ # 写入 .gitignore
155
+ (target / ".gitignore").write_text(
156
+ "__pycache__/\n*.py[cod]\n*.egg-info/\ndist/\nbuild/\n.env\n"
157
+ "config.*.env\n.pytest_cache/\nreports/\n.mypy_cache/\n.idea/\n.vscode/\n"
158
+ )
159
+
160
+ # 写入环境配置
161
+ env_content = (
162
+ f"# {project_dir} — {config['env_name']} 环境\n"
163
+ f"ENV_NAME={config['env_name']}\n"
164
+ f"BASE_URL={config['base_url']}\n"
165
+ f"API_PREFIX={config['api_prefix']}\n"
166
+ f"AUTH_HEADER_NAME=authorization\n"
167
+ f"LANG=zh-CN\n"
168
+ )
169
+ env_file = target / f"config.{config['env_name']}.env"
170
+ env_file.write_text(env_content)
171
+
172
+ # 写入空的 tests/endpoints/__init__.py
173
+ init_file = target / "tests/endpoints/__init__.py"
174
+ init_file.write_text("")
175
+
176
+ # 完成提示
177
+ typer.echo(f"""
178
+ ✅ 项目已创建: {target}/
179
+ ──────────────────────────────
180
+ cd {project_dir}
181
+ pip install -e ".[dev]"
182
+ pytest -v
183
+
184
+ 然后告诉 AI 你的接口信息,开始写测试。
185
+ ──────────────────────────────
186
+ """)
@@ -0,0 +1,45 @@
1
+ # {{ project_name }} — AI 开发者指南
2
+
3
+ > 这个文件告诉 AI 助手本项目的确切约定。每次生成代码前先读这个文件。
4
+
5
+ ## 首次使用
6
+
7
+ 你可以用自然语言告诉我接口信息,我来生成测试。
8
+
9
+ ## AI 能做什么
10
+
11
+ | 能力 | 说明 |
12
+ |------|------|
13
+ | **新增单接口测试** | 给我 curl 或接口文档,生成 endpoint 模型 + 测试脚本 |
14
+ | **新增 E2E 业务链路测试** | 你用自然语言描述业务流程,AI 生成多步骤场景 |
15
+ | **测试设计 review** | AI 先出测试用例表格,你确认后再写代码 |
16
+
17
+ ## 你需要提供什么
18
+
19
+ **单接口:** curl 命令(最推荐)/ OpenAPI / Postman / 文档链接 / URL+参数 / HAR 文件
20
+
21
+ **E2E 链路:** 自然语言描述,包含 4 个信息:
22
+ 1. 链路做什么(如"先查销售状态,再拿第一个状态去筛库存")
23
+ 2. 每一步调什么接口
24
+ 3. 步骤间数据怎么传
25
+ 4. 验证什么
26
+
27
+ ## 文件结构
28
+
29
+ ```
30
+ tests/
31
+ conftest.py # 全局 fixtures(client, env_config, e2e_context)
32
+ endpoints/ # AI 生成的测试放这里
33
+ e2e/ # AI 生成的 E2E 测试放这里
34
+ config.*.env # 环境配置(按需编辑)
35
+ ```
36
+
37
+ ## 工作流程
38
+
39
+ ```
40
+ ① 你提供 curl 或描述业务场景
41
+ ② AI 试调接口 → 设计测试用例 → 展示给你 review
42
+ ③ 你确认 / 补充业务场景
43
+ ④ AI 写代码
44
+ ⑤ pytest -v 跑通
45
+ ```
@@ -0,0 +1,31 @@
1
+ # {{ project_name }}
2
+
3
+ API 自动化测试项目。基于 `api-test-toolkit` 框架。
4
+
5
+ ## 快速开始
6
+
7
+ ```bash
8
+ pip install -e ".[dev]"
9
+ pytest -v
10
+ ```
11
+
12
+ ## 添加新 API 测试
13
+
14
+ 提供 curl 或接口文档给 AI,AI 会按以下流程工作:
15
+
16
+ ```
17
+ ① 你提供 curl / 描述业务场景
18
+ ② AI 试调接口 → 设计测试用例 → 展示给你 review
19
+ ③ 你确认 / 补充业务场景
20
+ ④ AI 写 endpoint 模型 + 测试
21
+ ⑤ pytest -v 跑通
22
+ ```
23
+
24
+ ## 常用命令
25
+
26
+ | 命令 | 说明 |
27
+ |------|------|
28
+ | `pip install -e ".[dev]"` | 安装依赖 |
29
+ | `pytest -v` | 运行测试 |
30
+ | `pytest -m smoke` | 只跑冒烟测试 |
31
+ | `pytest -m e2e` | 只跑 E2E 链路测试 |
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta:__legacy__"
4
+
5
+ [project]
6
+ name = "{{ project_name }}"
7
+ version = "0.1.0"
8
+ description = "API 自动化测试项目"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "api-test-toolkit>={{ api_test_kit_version }}",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "pytest-xdist>=3.5",
18
+ "pytest-html>=4.1",
19
+ "responses>=0.25",
20
+ "ruff>=0.3",
21
+ ]
@@ -0,0 +1,11 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ python_classes = Test*
5
+ python_functions = test_*
6
+ markers =
7
+ smoke: 快速冒烟测试
8
+ e2e: 端到端业务链路测试
9
+ log_cli = true
10
+ log_cli_level = INFO
11
+ addopts = --tb=short -v