mcpField
T
telegram-test-specs

telegram-test-specs

github.com/agntdev测试与质量239 次安装17 周前更新
代码仓库

关于 telegram-test-specs

telegram-test-specs 是编写 Telegram Bot 对话 JSON 规格的指南与技能,面向使用 agntdev 发布门禁的开发者。它通过无令牌的进程内测试捕获并核对 API 调用。

主要功能

  1. 01用 BotSpec JSON 定义对话
  2. 02重放合成的 Telegram 更新
  3. 03在无网络时捕获 API 调用
  4. 04按子序列或严格顺序匹配调用
  5. 05检查已声明命令的覆盖情况
  6. 06按功能组织规格文件

适用场景

  1. 01无需 Telegram 令牌测试 Bot 回复
  2. 02断言回调和消息 API 调用
  3. 03查找缺少有效测试覆盖的命令
  4. 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,而是:

  1. 在进程内构建你的机器人(仅导入 makeBot())
  2. 向其提供合成的 Update(无需网络)
  3. 捕获机器人尝试发出的每个 API 调用
  4. 比较捕获的调用与预期调用
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!" } }
      ]
    }
  ]
}
字段
字段类型必填备注
namestring是唯一且易于理解
strictboolean否默认值为 false
stepsSpecStep[]是按顺序排列的用户操作和预期响应
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 个实际测试该命令的规格。

当满足以下条件时,该规格对某个命令来说才算“实际有效”:

  1. send 步骤包含一段 /command 文本
  2. 该步骤的 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。

常见错误

  1. 发送命令时 expect[] 为空 — 会提高覆盖率,却不做任何断言。测试框架会拒绝。
  2. 忘记调用 answerCallbackQuery — 子序列匹配会掩盖这个问题,但真实用户会看到一直转动的加载指示器。
  3. 设置 strict: true 却未包含附带调用 — 几乎每个回调处理器都会调用 answerCallbackQuery。如果启用了 strict,请将其包含在 expect 中。
  4. 依赖不同 spec 之间的 session — 测试框架会为每个 spec 创建新的 bot。每次都会从 initial() 开始 session。
  5. 未声明所有命令 — 覆盖率门禁使用已声明的命令列表。没有 spec 的处理器会导致门禁失败。
  6. 大小写不匹配 — 声明了 /Book,但 spec 发送 /book → 在覆盖率统计中会被视为不同的命令。

评论

支持 Markdown