Skip to content

Commit 0e09ae8

Browse files
authored
Merge pull request #201 from yourtion/feat/docs-driven-by-test
docs: 测试驱动的文档(test-agent → ✅ + 真实示例)
2 parents 1a5d8cc + a3b01e1 commit 0e09ae8

9 files changed

Lines changed: 191 additions & 7 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,64 @@ it('应拒绝未成年用户', async () => {
402402
});
403403
```
404404

405+
`.takeExample(name)``.success()` 前调用,会把这次请求的真实 `input`/`headers`/`output` 回填为该路由的文档示例(见下节「测试驱动的文档」):
406+
407+
```typescript
408+
it('创建用户并记录示例', async () => {
409+
await api.test.post('/api/users')
410+
.input({ name: 'Tom', email: 'tom@example.com', age: 20 })
411+
.takeExample('正常创建') // 真实响应被存为该路由的文档示例
412+
.success();
413+
});
414+
```
415+
416+
## 测试驱动的文档
417+
418+
测试脚手架与文档生成是**联动**的——这是 erest「测试即文档」的设计:用 test-agent 跑过的路由,
419+
生成的 markdown 文档里会显示 ✅(而非 ❌),`.takeExample()` 记录的真实请求/响应会成为文档示例。
420+
421+
**两个标记的来源:**
422+
423+
| 行为 | 效果 | 对应 API |
424+
|------|------|----------|
425+
| `.success()` / `.error()` / `.raw()` 读取响应 | 该路由 `tested = true` → markdown 标题显示 ✅ | `agent.output()` 内部设置 |
426+
| `.takeExample(name)``.success()` 前) | 真实 input/headers/output 存为该路由 example → 文档「使用示例」段落 | `agent.saveExample()` |
427+
428+
不跑任何测试、直接 `genDocs()` 时,所有路由是 ❌、无示例(`generate.js` 的 mock 模式);
429+
跑过 test-agent 后,被测路由变 ✅ 且带真实数据。
430+
431+
**完整示例**[examples/docs/generate-from-test.js](./examples/docs/generate-from-test.js)):
432+
433+
```typescript
434+
const api = new ERest({
435+
info: { /* ... */ },
436+
groups: { /* ... */ },
437+
docs: { markdown: true, wiki: true, index: true }, // 开启 markdown 文档生成
438+
});
439+
registerApi(api, store, { /* hooks */ });
440+
api.bind({ adapter: new ExpressAdapter(), app, router: express.Router });
441+
api.initTest(app);
442+
443+
// 用 test-agent 跑真实请求:翻转 tested(✅) + 回填真实示例
444+
await api.test.get('/public/posts').takeExample('文章列表').success();
445+
await api.test.post('/posts/posts')
446+
.headers({ 'X-Admin-Token': 'user-token' })
447+
.input({ slug: 'new', title: '新文章', content: '内容' })
448+
.takeExample('创建文章')
449+
.success();
450+
451+
// 落盘:✅ 标记与真实示例一并写入 markdown
452+
api.genDocs('./docs/out-from-test/', false);
453+
```
454+
455+
**运行:**
456+
457+
```bash
458+
pnpm --filter erest-example docs:test # 测试驱动文档(✅ + 真实示例)
459+
pnpm --filter erest-example docs # 对比:mock 文档(全 ❌、无示例)
460+
```
461+
462+
405463
## 类型安全的 Handler:`registerTyped`
406464

407465
`register()` 的 handler 入参无类型,需要手动断言。`registerTyped()` 基于 Zod schema 自动推导

examples/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
| `response()` schema | `src/api.js` | 用户列表 API 声明响应 schema |
2222
| 分层参数 | `src/api.js` | `PUT /posts/:id` 的 path id 与 body 互不覆盖 |
2323
| 文档生成 `genDocs` | `docs/generate.js` | swagger / postman / markdown / axios SDK |
24+
| 测试驱动文档 `takeExample` + `genDocs` | `docs/generate-from-test.js` | test-agent 跑真实请求 → 文档 ✅ + 真实示例 |
2425
| 测试集成 `initTest` + `api.test` | `test/api.test.js` | vitest 写 success / error / takeExample |
2526

2627
## 目录结构
@@ -38,7 +39,8 @@ examples/
3839
│ ├── express.js # Express 入口
3940
│ └── koa.js # Koa 入口
4041
├── docs/
41-
│ └── generate.js # 文档生成脚本(npm run docs)
42+
│ ├── generate.js # 文档生成脚本(npm run docs,mock 模式)
43+
│ └── generate-from-test.js # 测试驱动文档(npm run docs:test,✅ + 真实示例)
4244
├── test/
4345
│ └── api.test.js # vitest 测试套件(npm test)
4446
├── types/
@@ -114,6 +116,20 @@ pnpm --filter erest-example docs # 生成到 docs/out/
114116
- `Home.md` + 各组 `.md` + `errors.md` / `schema.md` / `types.md` — Markdown 文档
115117
- `sdk.js` — 基于 axios 的前端 SDK
116118

119+
> 上面的 `npm run docs` 不跑测试,markdown 里所有 API 显示 ❌、无示例(mock 模式)。
120+
121+
### 测试驱动文档(✅ + 真实示例)
122+
123+
```bash
124+
pnpm --filter erest-example docs:test # 生成到 docs/out-from-test/
125+
```
126+
127+
用 test-agent 跑真实请求(`.success().takeExample()`),让文档通过测试变绿:
128+
129+
- 被测路由显示 ****`tested` 标记翻转),未测的保持 ❌
130+
- 「使用示例」段落的 `output`**真实响应**(非 mock)
131+
- 详见 [docs/generate-from-test.js](./docs/generate-from-test.js),根 README 的「测试驱动的文档」章节有原理说明
132+
117133
## 设计说明:handler 与 hooks 均框架无关
118134

119135
erest v3 标准化后,**业务 handler 与 before/middleware 钩子都框架无关**,同一份代码被
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* 测试驱动文档生成脚本(演示 erest「测试即文档」能力)。
3+
*
4+
* 与 generate.js 的区别:
5+
* - generate.js:只装配实例 + genDocs,不跑请求 → 所有 API 在文档里显示 ❌、无真实示例
6+
* - 本脚本:用 test-agent 跑真实请求(.success().takeExample())→ 文档显示 ✅ + 真实示例
7+
*
8+
* 原理(同一 ERest 实例上完成全链路):
9+
* 1. .success() / .error() / .raw() 读取响应输出 → 翻转对应路由的 tested 标记 → markdown 标题显示 ✅
10+
* 2. .takeExample(name)(在 .success() 前调用)→ 把真实 input/headers/output 回填为该路由的示例
11+
* 3. genDocs() 落盘时,✅ 标记与示例数据一并写入 markdown
12+
*
13+
* 运行:npm run docs:test
14+
*/
15+
import express from "express";
16+
import { fileURLToPath } from "node:url";
17+
import { dirname, resolve } from "node:path";
18+
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
19+
import ERest from "erest";
20+
import { ExpressAdapter } from "@erest/express";
21+
import { API_INFO, GROUPS, registerApi } from "../src/api.js";
22+
import { createStore } from "../src/store.js";
23+
import { authBefore, adminBefore, logMiddleware, timingBefore } from "../src/hooks.js";
24+
25+
const __dirname = dirname(fileURLToPath(import.meta.url));
26+
const outDir = resolve(__dirname, "out-from-test");
27+
mkdirSync(outDir, { recursive: true });
28+
29+
// 装配 ERest 实例(开启 markdown 文档生成)
30+
const store = createStore();
31+
const app = express();
32+
app.use(express.json());
33+
app.use(express.urlencoded({ extended: true }));
34+
35+
const api = new ERest({
36+
info: API_INFO,
37+
groups: GROUPS,
38+
forceGroup: true,
39+
// wiki: 每个分组生成独立 .md(public.md/post.md/admin.md);index: 生成目录页
40+
docs: { markdown: true, wiki: true, index: true },
41+
});
42+
43+
registerApi(api, store, {
44+
authBefore: authBefore(store),
45+
adminBefore: adminBefore(),
46+
logMiddleware: logMiddleware(),
47+
timingBefore: timingBefore(),
48+
});
49+
50+
api.bind({ adapter: new ExpressAdapter(), app, router: express.Router });
51+
52+
// 错误处理中间件(initTest 用 fetch 驱动测试,需错误以 JSON 响应体返回)
53+
app.use((err, _req, res, _next) => {
54+
res.status(err.statusCode || err.status || 400).json({ error: err.message });
55+
});
56+
57+
// 初始化测试系统(接收 express app,内部 lazy listen 随机端口)
58+
api.initTest(app);
59+
60+
// —— 用 test-agent 跑代表性请求,让文档通过测试变绿(✅)+ 回填真实示例 ——
61+
// 路径用完整 group 前缀(public/ /posts/ /admin/),鉴权用 X-Admin-Token header
62+
const examples = [
63+
// public 组(无鉴权)
64+
() => api.test.get("/public/posts").takeExample("已发布文章列表").success(),
65+
() => api.test.get("/public/posts/hello-erest").takeExample("文章详情").success(),
66+
// post 组(需 user-token)
67+
() =>
68+
api.test
69+
.get("/posts/posts")
70+
.headers({ "X-Admin-Token": "user-token" })
71+
.input({ status: "published" })
72+
.takeExample("我的文章列表")
73+
.success(),
74+
() =>
75+
api.test
76+
.post("/posts/posts")
77+
.headers({ "X-Admin-Token": "user-token" })
78+
.input({ slug: "test-driven-docs", title: "测试驱动文档", content: "测试即文档" })
79+
.takeExample("创建文章")
80+
.success(),
81+
// admin 组(需 admin-token)
82+
() => api.test.get("/admin/users").headers({ "X-Admin-Token": "admin-token" }).takeExample("用户列表").success(),
83+
() => api.test.get("/admin/stats").headers({ "X-Admin-Token": "admin-token" }).takeExample("统计信息").success(),
84+
];
85+
86+
const results = await Promise.allSettled(examples.map((fn) => fn()));
87+
const failures = results.filter((r) => r.status === "rejected");
88+
if (failures.length > 0) {
89+
for (const f of failures) console.error(" ✗ 请求失败:", f.reason?.message || f.reason);
90+
process.exit(1);
91+
}
92+
93+
// 生成并保存文档(onExit=false 同步落盘)
94+
api.genDocs(outDir, false);
95+
96+
// 统计 ✅ 覆盖率(遍历分组 md 文件)
97+
let testedCount = 0;
98+
let totalApis = 0;
99+
for (const f of readdirSync(outDir)) {
100+
if (!f.endsWith(".md")) continue;
101+
const md = readFileSync(resolve(outDir, f), "utf8");
102+
testedCount += (md.match(/^## .+ /gm) || []).length;
103+
totalApis += (md.match(/^## .+ []/gm) || []).length;
104+
}
105+
106+
console.log(`测试驱动文档已生成到 ${outDir}/`);
107+
console.log(` - ${testedCount}/${totalApis} 个路由通过测试变绿 ✅`);
108+
console.log(" - 示例数据来自 test-agent 真实响应(非 mock)");
109+
console.log("\n对比:npm run docs 生成 mock 文档(全部 ❌、无示例)");

examples/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"dev:express": "node --watch src/entries/express.js",
1313
"dev:koa": "node --watch src/entries/koa.js",
1414
"test": "vitest run",
15-
"docs": "node docs/generate.js"
15+
"docs": "node docs/generate.js",
16+
"docs:test": "node docs/generate-from-test.js"
1617
},
1718
"dependencies": {
1819
"@leizm/web": "^2.7.0",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "erest",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "Framework-agnostic REST API framework with Zod validation, auto docs & test scaffolding. Express/Koa/@leizm/web adapters as separate packages.",
55
"type": "module",
66
"main": "dist/lib/index.js",

packages/erest-express/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@erest/express",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "Express adapter for erest",
55
"type": "module",
66
"main": "./dist/index.js",

packages/erest-gen/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@erest/gen",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "erest codegen CLI:从 Zod schema 生成 handler 骨架等(实验性,独立于 erest 主版本演进)",
55
"type": "module",
66
"bin": {

packages/erest-koa/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@erest/koa",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "Koa adapter for erest",
55
"type": "module",
66
"main": "./dist/index.js",

packages/erest-leizmweb/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@erest/leizmweb",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "@leizm/web adapter for erest",
55
"type": "module",
66
"main": "./dist/index.js",

0 commit comments

Comments
 (0)