n8n 评估运行器 是做什么的?
何时触发
- 添加或修改筛选逻辑、关键词列表、验证器或简历映射时
- 重构 Code 节点的纯函数逻辑时
- 合并工作流变更前——回归防护
- 生产事故后——将失败固化到测试中
- “我如何对 n8n 代码进行单元测试?” / “如何在不消耗 API 额度的情况下进行测试?”
这是什么(以及不是什么)
| 这是 | 这不是 |
|---|---|
| 一个仅含逻辑的 n8n 工作流,使用伪造输入 | 针对实时 API 的集成测试 |
| 除了最终报告发送外无外部调用——约 3 秒运行完成 | 性能测试 |
| 推送到 Telegram 的通过/失败/待处理计数 | 端到端流程手动冒烟测试的替代品 |
| Code 节点中纯 JS 的回归网络 | HTTP / Gmail / Telegram 节点行为的覆盖 |
成本: 无 API 额度。仅内存中的伪造输入。Telegram 报告是唯一的外部调用。
架构
[手动触发器] → [运行所有测试(Code 节点)] → [格式化报告(Code 节点)] → [发送报告(Telegram)]
每个逻辑面一个工作流。按需重新运行。
标准测试运行器 Code 节点
const results = [];
function assert(suite, testName, condition, detail) {
results.push({ suite, test: testName, pass: condition, detail: detail || '' });
}
// ============================================================
// 套件 1 — <名称>
// ============================================================
// 此处逐字复制生产函数(不要导入——Code 节点不共享作用域)
function passesJs10Filter(job) {
// ... 与生产节点相同的代码 ...
}
// 测试
assert('js-10', '机密职位被拒绝',
!passesJs10Filter({
title: 'Technical Writer',
description: 'Clearance required.',
location: 'remote'
}),
'需要机密的职位必须触发拒绝');
assert('js-10', '有效的远程技术写手通过',
passesJs10Filter({
title: 'Technical Writer',
description: 'Write API documentation for cloud platform.',
location: 'remote'
}),
'有效的远程技术写手应通过所有筛选');
// 对于尚未实现的功能:
results.push({
suite: 'js-10',
test: '30天截止日期(待处理 — 积压)',
pass: null,
detail: '功能尚未实现。添加 postedAt 日期检查,然后重新运行。'
});
// ============================================================
// 汇总
// ============================================================
const passed = results.filter(r => r.pass === true);
const failed = results.filter(r => r.pass === false);
const pending = results.filter(r => r.pass === null);
return [{ json: {
summary: { total: results.length, passed: passed.length, failed: failed.length, pending: pending.length },
results, failed, pending
}}];
报告格式化器
const { summary, results } = $input.first().json;
const passIcon = '✅';
const failIcon = '❌';
const pendIcon = '⏳';
const topEmoji = summary.failed === 0 ? '🟢' : (summary.failed <= 3 ? '🟡' : '🔴');
const lines = [
`<b>${topEmoji} <名称> — 逻辑评估</b>`,
`${passIcon} ${summary.passed} 通过 ${failIcon} ${summary.failed} 失败 ${pendIcon} ${summary.pending} 待处理`,
''
];
const suites = {};
for (const r of results) (suites[r.suite] = suites[r.suite] || []).push(r);
for (const [suite, tests] of Object.entries(suites)) {
const suitePass = tests.every(t => t.pass !== false);
lines.push(`<b>${suitePass ? passIcon : failIcon} ${suite}</b>`);
for (const t of tests) {
const icon = t.pass === true ? passIcon : (t.pass === false ? failIcon : pendIcon);
lines.push(` ${icon} ${t.test}`);
if (t.pass !== true) lines.push(` <i>→ ${(t.detail || '').substring(0, 140)}</i>`);
}
lines.push('');
}
const now = new Date().toLocaleString('en-US', {
timeZone: 'UTC', dateStyle: 'short', timeStyle: 'short'
});
lines.push(`<i>运行时间:${now} UTC</i>`);
return [{ json: { message: lines.join('\n') } }];
Telegram 发送节点(环境变量模式,无需 n8n 凭证)
{
"method": "POST",
"url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
"sendBody": true,
"specifyBody": "keypair",
"bodyParameters": {
"parameters": [
{ "name": "chat_id", "value": "={{ $env.TELEGRAM_CHAT_ID }}" },
{ "name": "text", "value": "={{ $json.message }}" },
{ "name": "parse_mode", "value": "HTML" }
]
}
}
测试内容(优先级顺序)
- 拒绝路径覆盖 — 记录可能被筛选出的每一个原因
- 边界条件 — 阈值处、略低于、略高于
- 无效输入回退 —
undefined、null、0、'None'、错误类型 → 预期默认值 - 已知良好的真实数据 — 至少一个来自过去确认可运行运行的示例
- 已知不良的真实数据 — 至少一个曾导致过去事故的示例
- 子串匹配与精确匹配 — 例如
'HR Dept'与'Human Resources Department' - 待处理功能 — 写为
pass: null,以便在报告中显示为积压项
测试命名约定
<套件>: <人类可读的断言>,详情字段命名底层规则或过去错误引用:
assert('js-22', 'Human Resources Department 被拒绝',
!isValidContact('Human Resources Department'),
'BAD_CONTAINS_CONTACT 子串匹配必须捕获此情况(第5次会话错误修复)');
“将过去错误转化为测试”规则
你的经验教训库中每一条命名了特定输入 → 错误行为的条目都是一个等待编写的测试。
来自真实经验的示例:
“聚合扇区报价在计算 1 个月变动而不是 1 天变动,因为 meta.previousClose 在范围=1mo 时返回了图表开始价格。”
→ 测试:
assert('aggregate', '1天变动从 closes[] 计算,而非 meta.previousClose',
computeChange({
chart: { result: [{ meta: { previousClose: 100 }, indicators: { quote: [{ close: [100, 98, 99, 105] }] } }] }
}) === ((105 - 99) / 99) * 100,
'meta.previousClose 可能不可靠;必须使用 closes[-2]');
何时运行
- 对筛选逻辑、关键词列表、验证器、简历映射进行任何更改之前
- 更改之后——确认没有回归
- 添加任何经验教训库条目之后——编写本可以捕获该问题的测试
- 部署任何新工作流版本之前
状态图标含义
| 图标 | 含义 |
|---|---|
| 🟢(绿色) | 0 个失败——此套件通过;仅在其他检查也通过时部署 |
| 🟡(黄色) | 1-3 个失败——修复前阻止部署 |
| 🔴(红色) | 4 个以上失败——阻止部署;可能是更广泛的故障 |
待处理(⏳)不计入红色/黄色——它作为积压项单独跟踪。待处理测试是覆盖缺口:在部署说明中列出它们。
反模式
| 反模式 | 应该怎么做 |
|---|---|
| 针对实时 API 测试 | 仅伪造输入——评估套件仅限逻辑 |
| 跳过待处理功能(失去可见性) | 添加为 pass: null,附带解释缺失内容的详情 |
| 导入生产代码 | 逐字复制——Code 节点不共享作用域 |
| 一个巨大的测试函数 | 每个断言一个 assert()——生成可读报告 |
| 错误修复后丢弃测试 | 永远保留——这就是回归防护 |
| 仅测试快乐路径 | 拒绝路径覆盖是最有价值的测试 |
| 仅在更改后运行 | 在更改前后都运行——确认你没有引入回归 |
| 在 workflow JSON 中硬编码聊天 ID / 令牌 | 使用 $env.TELEGRAM_BOT_TOKEN / $env.TELEGRAM_CHAT_ID |
部署前检查清单
- 评估工作流在 < 10 秒内运行
- 所有套件在报告中分组
- 生产代码已逐字复制(非改写)
- 每个拒绝路径至少 1 个测试
- 每个套件至少 1 个“已知良好的真实数据”测试
- 待处理功能表示为
pass: null并附带详情 - 报告适合一条 Telegram 消息(< 4000 字符)
参考
- n8n Code 节点文档:https://docs.n8n.io/code/code-node/
- Telegram Bot API 格式化:https://core.telegram.org/bots/api#formatting-options