关于 telegram-test-specs
telegram-test-specs 是编写 Telegram Bot 对话 JSON 规格的指南与技能,面向使用 agntdev 发布门禁的开发者。它通过无令牌的进程内测试捕获并核对 API 调用。
主要功能
- 01用 BotSpec JSON 定义对话
- 02重放合成的 Telegram 更新
- 03在无网络时捕获 API 调用
- 04按子序列或严格顺序匹配调用
- 05检查已声明命令的覆盖情况
- 06按功能组织规格文件
适用场景
- 01无需 Telegram 令牌测试 Bot 回复
- 02断言回调和消息 API 调用
- 03查找缺少有效测试覆盖的命令
- 04通过按功能划分的规格运行发布门禁测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agntdev/ skills --skill telegram-test-specs安装后在 Claude Code 中输入/telegram-test-specs确认已加载。
详细介绍
telegram-test-specs 技能
如何为 Telegram 机器人编写对话测试规范——为什么要进行无令牌测试、测试框架如何运作,以及规范格式。
为 agntdev 流水线打造。 tests-gate 是 客观的发布门禁——每份规范都必须通过,机器人才能 发布。请参阅 agnt-cli-builder,了解 构建循环以及门禁在其中的作用。
⚠️ 安全提示——进程内运行机器人。 本技能介绍如何在测试框架中进程内运行机器人的
makeBot(),这样测试框架就能重放Update对象并捕获 API 调用,无需真实的 BotFather 令牌。进程内导入正是测试框架的核心,但这也意味着测试框架会继承被导入的机器人代码能够访问的一切:文件系统、process.env、网络。 不要将测试框架指向你未编写或审查过的机器人代码。请在隔离目录中运行;不要在同一个 shell 中设置敏感环境变量;首次运行npm test前,请审查src/handlers/*和src/middleware/*。stdout 上的 verdict nonce(GATE:<nonce>:...,§4)用于向发布器验证结果——请像对待部署密钥一样对待此 nonce:不要将其发到聊天中,也不要记录到共享频道。
1. 为什么要进行无令牌测试
通常,测试 Telegram 机器人需要真实的机器人令牌,并调用网络接口 api.telegram.org。这意味着:
- 每次测试都需要 BotFather 令牌
- 测试会调用真实 API(速度慢,而且受速率限制)
- 没有密钥就无法在 CI 中运行
- 难以断言精确的 API 调用
测试框架方案
测试框架不调用 Telegram API,而是:
- 在进程内构建你的机器人(仅导入
makeBot()) - 向其提供合成的 Update(无需网络)
- 捕获机器人尝试发出的每个 API 调用
- 比较捕获的调用与预期调用
BotSpec JSON → harness feeds synthetic Updates → bot handles them → captures API calls → compares vs expected
无需 Telegram。无需令牌。无需网络。可在任何地方运行。结果确定。
门禁判定
测试框架会在 stdout 上输出一行机器可读内容:
GATE:<nonce>:{"ok":true,"total":3,"passed":3,"failed":0,"coverage":{...},"results":[...]}
ok: true→ 所有规范均通过,且所有声明的命令均已覆盖- 退出代码始终为
0(判定结果在 JSON 中;非零表示测试框架崩溃) - Nonce 用于验证判定结果(机器人代码无法伪造)
2. 测试框架如何运作
机器人工厂
测试框架会导入你的 makeBot(),并针对每份规范重新调用:
import { makeBot } from "./src/index";
// Harness does this internally for each spec:
const bot = makeBot(); // fresh bot, fresh session, fresh state
捕获转换器
测试框架会安装一个 grammY 转换器,拦截每个发出的 API 调用:
bot.api.config.use(async (prev, method, payload) => {
// Instead of calling api.telegram.org:
calls.push({ method, payload }); // record it
return { ok: true, result: stub }; // return fake success
});
这意味着 ctx.reply("Hi")、ctx.editMessageText(...)、ctx.answerCallbackQuery() 等调用都会被捕获,不会触及网络。
虚拟 botInfo
grammY 通常会在启动时调用 getMe。测试框架会跳过此步骤:
bot.botInfo = { id: 1, is_bot: true, first_name: "TestBot", username: "test_bot", ... };
合成 Update
测试框架会根据你的规范构建与 grammY 兼容的 Update 对象:
// { "send": { "text": "/start" } } becomes:
{
update_id: 1,
message: {
message_id: 1,
chat: { id: 1, type: "private" },
from: { id: 1, first_name: "User" },
text: "/start",
entities: [{ type: "bot_command", offset: 0, length: 6 }]
}
}
/command文本会自动添加bot_command实体 → grammY 命令路由器会匹配chatId默认值为1,userId默认值为1- 回调查询包含原始消息 →
editMessageText可正常工作
3. BotSpec 格式
规范文件是描述对话的 JSON 对象:
{
"name": "start command greets user",
"strict": false,
"steps": [
{
"send": { "text": "/start" },
"expect": [
{ "method": "sendMessage", "payload": { "text": "Welcome!" } }
]
}
]
}
字段
| 字段 | 类型 | 必填 | 备注 |
|---|---|---|---|
name | string | 是 | 唯一且易于理解 |
strict | boolean | 否 | 默认值为 false |
steps | SpecStep[] | 是 | 按顺序排列的用户操作和预期响应 |
SpecStep — send(用户执行的操作)
三种变体:
// 1. Text message
{ "send": { "text": "/start" } }
// 2. Text with specific chat/user
{ "send": { "text": "/book", "chatId": 42, "userId": 99 } }
// 3. Callback button tap
{ "send": { "callback": "menu:book", "messageId": 100 } }
// 4. Raw Update object (advanced)
{ "send": { "update": { "update_id": 1, "message": {...} } } }
SpecStep — expect(机器人应回复的内容)
// Assert method was called with specific payload (deep-subset match)
{ "method": "sendMessage", "payload": { "text": "Welcome!" } }
// Assert method was called, any payload
{ "method": "editMessageText" }
// Assert method was called (no payload check)
{ "method": "answerCallbackQuery" }
深度子集匹配: payload: { text: "Welcome!" } 可匹配 { chat_id: 1, text: "Welcome!", ... }。你可以断言自己关心的内容,而无需固定 chat_id、message_id、parse_mode 等自动填充的字段。
匹配模式
子序列(默认,strict: false): 每个预期调用都必须按顺序出现,但允许存在额外调用。
{
"send": { "callback": "menu:next" },
"expect": [{ "method": "editMessageText" }]
}
// pass — answerCallbackQuery fired too, but was incidental
严格模式(strict: true): 数量精确匹配,且按位置匹配。适用于“除此之外别无其他”的情况。
{
"strict": true,
"steps": [
{ "send": { "callback": "menu:next" }, "expect": [
{ "method": "editMessageText" }
] }
]
}
// fail — answerCallbackQuery fired but wasn't in expect[]
建议:大多数规范使用子序列匹配;仅在需要特定断言时使用严格模式。
4. 命令覆盖规则
门禁会检查:每个声明的命令都必须至少有 1 个实际测试该命令的规格。
当满足以下条件时,该规格对某个命令来说才算“实际有效”:
send步骤包含一段/command文本- 该步骤的
expect[]至少有 1 个条目
// ✅ Counts toward /book coverage:
{ "send": { "text": "/book" }, "expect": [{ "method": "sendMessage" }] }
// ❌ Does NOT count (empty expect — no assertion):
{ "send": { "text": "/book" }, "expect": [] }
// ❌ Does NOT count (not a command — no bot_command entity added):
{ "send": { "text": "hello" }, "expect": [{ "method": "sendMessage" }] }
命令区分大小写:/Book 和 /book 是不同的命令。grammY 会分别路由它们,覆盖率也会分别跟踪。
覆盖率报告(来自 GATE 判定结果):
{
"declared": ["book", "cancel", "start"],
"covered": ["book", "start"],
"missing": ["cancel"],
"fraction": 0.666
}
门禁通过要求 fraction: 1(除非未声明任何命令,此时会自动设为 1)。
5. 测试框架 CLI
通过内联的测试框架 CLI 调用(由 npm run build 从 src/toolkit/harness/
构建到 dist/toolkit/harness/cli.js):
AGNTDEV_BOT_MODULE=./src/index.ts # module exporting makeBot()
AGNTDEV_SPECS_FILE=./specs.json # JSON array of BotSpec (legacy single-file) OR
AGNTDEV_SPECS_GLOB=./tests/specs/*.json # per-feature pattern (whole_bot, canonical)
AGNTDEV_COMMANDS_FILE=./commands.json # string[] of declared commands (optional)
AGNTDEV_GATE_NONCE=abc123 # nonce for verdict auth
对于 whole_bot 项目,设置 AGNTDEV_SPECS_GLOB,让测试框架匹配每个功能对应的 tests/specs/<slug>.json 并在门禁运行时将它们合并。完整的按功能拆分模式见下方第 6 节。
完整示例:预订机器人规格
[
{
"name": "/start greets user",
"steps": [
{ "send": { "text": "/start" }, "expect": [{ "method": "sendMessage", "payload": { "text": "Welcome!" } }] }
]
},
{
"name": "/book flow",
"steps": [
{ "send": { "text": "/book" }, "expect": [{ "method": "sendMessage", "payload": { "text": "Choose a service:" } }] },
{ "send": { "callback": "select:cut" }, "expect": [{ "method": "editMessageText", "payload": { "text": "Pick a time:" } }] },
{ "send": { "callback": "slot:14:00" }, "expect": [{ "method": "editMessageText", "payload": { "text": "Booked!" } }] }
]
},
{
"name": "/cancel flow",
"steps": [
{ "send": { "text": "/cancel" }, "expect": [{ "method": "sendMessage" }] },
{ "send": { "callback": "confirm:cancel:yes" }, "expect": [{ "method": "editMessageText", "payload": { "text": "Cancelled." } }] }
]
}
]
快速参考
| 概念 | 实现方式 |
|---|---|
| 机器人工厂 | export function makeBot() — 每个规格使用一个全新的机器人 |
| 无网络 | 捕获转换器 + 虚拟 botInfo |
| 合成输入 | { text: "/cmd" }、{ callback: "data" }、{ update: {...} } |
| 预期输出 | { method: "sendMessage", payload: { text: "Hi" } }(深度子集匹配) |
| 判定结果 | 在标准输出中输出 GATE:<nonce>:{"ok":bool, ...} |
| 覆盖率 | 每个声明的命令都需要至少 1 个 expect 非空的规格 |
6. 按功能拆分的规格文件(whole_bot 约定)
whole_bot 项目会将测试规格组织为每个功能一个 JSON
文件,放在 tests/specs/<slug>.json 中;每个 src/handlers/<slug>.ts 对应一个文件。平台会在门禁运行时匹配并合并这些文件,因此每个文件都是独立的。
文件布局
my-bot/
├── tests/
│ ├── specs/
│ │ ├── T01.json # one file per feature task
│ │ ├── T02.json
│ │ └── T03.json
│ ├── helpers.ts # if you use programmatic tests (see advanced skill)
│ └── start.test.ts
每个文件都是由 BotSpec 对象组成的 JSON 数组(形状与上方第 3 节相同)。示例 tests/specs/T02.json:
[
{
"name": "T02: /balance shows the user's balance",
"steps": [
{ "send": { "text": "/balance" }, "expect": [
{ "method": "sendMessage", "payload": { "text": "Balance: 42.5" } }
] }
]
}
]
为什么按文件拆分(而不是使用一个大型 specs.json)?
- 避免合并冲突。 多个 Agent(或后续 PR)可以并行处理不同功能;每个功能使用一个规格文件,就不会因共用文件而互相阻塞。
- 按功能隔离。 门禁会将每个规格文件映射到文件名中的功能 slug。
tests/specs/<slug>.json失败时,可以直接定位到需要修复的处理程序。 - 手动编写。 bot-starter 模板会提供按功能划分的目录,但规格内容需要由你编写——让处理程序的回复文本与
expect.payload.text完全一致。
编写按功能拆分的规格
规格文件内容与上方第 3 节中的 BotSpec JSON 形状相同。唯一的区别是文件位置:使用 tests/specs/<slug>.json,而不是一个大型 specs.json。文件名中的 slug 必须与任务 slug 完全一致(区分大小写)。
门禁运行时机
平台在发布时运行 tests-gate(在 whole_bot 构建循环中内联执行,并在完整性审查收敛后运行),会匹配 tests/specs/*.json,将它们合并到一个内存数组中,然后使用合并后的规格运行测试框架。每个规格的 GATE 结果都会归属到其源文件,因此失败时可以定位到需要修复的处理程序。
门禁采用失败即关闭策略。 缺失或无法读取的按功能拆分规格文件会导致硬性错误
GATE:<nonce>:{"ok":false,...,"error":"..."},而不会被静默跳过。被跳过的规格(例如平台无法获取或解析的规格文件)会在判定结果中附带原因。如果门禁因某一行“已跳过”而失败,问题出在规格文件本身,而不是门禁。
如果项目同时包含旧版规格和按功能拆分的规格(例如正在迁移),则优先采用按功能拆分的模式。如果存在 tests/specs/*.json,平台会忽略顶层的 specs.json。
常见错误
- 发送命令时
expect[]为空 — 会提高覆盖率,却不做任何断言。测试框架会拒绝。 - 忘记调用
answerCallbackQuery— 子序列匹配会掩盖这个问题,但真实用户会看到一直转动的加载指示器。 - 设置
strict: true却未包含附带调用 — 几乎每个回调处理器都会调用answerCallbackQuery。如果启用了 strict,请将其包含在 expect 中。 - 依赖不同 spec 之间的 session — 测试框架会为每个 spec 创建新的 bot。每次都会从
initial()开始 session。 - 未声明所有命令 — 覆盖率门禁使用已声明的命令列表。没有 spec 的处理器会导致门禁失败。
- 大小写不匹配 — 声明了
/Book,但 spec 发送/book→ 在覆盖率统计中会被视为不同的命令。