n8n Evaluations Runner
何時觸發
- 新增或修改過濾邏輯、關鍵字清單、驗證器或履歷對照表
- 重構 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} <NAME> — 邏輯評估</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 子字串比對必須捕捉此情況(Session 5 錯誤修復)');
「將過去錯誤轉換為測試」規則
在你的 Lessons Learned Library 中,每條提及特定輸入 → 錯誤行為的條目,都是一個等待被編寫的測試。
來自真實課程的範例:
"Aggregate Sector Quotes 正在計算 1-MONTH 變動而非 1-DAY,因為 meta.previousClose 在 range=1mo 時返回了圖表起始價格。"
→ 測試:
assert('aggregate', '1-day 變動是從 closes[] 計算,而非 meta.previousClose',
computeChange({
chart: { result: [{ meta: { previousClose: 100 }, indicators: { quote: [{ close: [100, 98, 99, 105] }] } }] }
}) === ((105 - 99) / 99) * 100,
'meta.previousClose 可能不可靠;必須使用 closes[-2]');
何時執行
- 對過濾邏輯、關鍵字清單、驗證器、履歷對照表進行任何變更之前
- 變更之後 — 確認沒有回歸問題
- 每次新增 LL Library 條目之後 — 編寫原本可以捕捉該錯誤的測試
- 任何新工作流程版本預部署前
狀態圖示含義
| 圖示 | 含義 |
|---|---|
| 🟢 (綠色) | 0 個失敗 — 此套件通過;只有在其他檢查也通過時才能部署 |
| 🟡 (黃色) | 1-3 個失敗 — 修復前禁止部署 |
| 🔴 (紅色) | 4+ 個失敗 — 禁止部署;可能存在廣泛性問題 |
待處理 (⏳) 不計入紅色/黃色 — 它會單獨追蹤為待辦事項。待處理測試是覆蓋缺口:請在部署說明中列出它們。
反模式
| 反模式 | 應採取的作法 |
|---|---|
| 對即時 API 進行測試 | 僅使用模擬輸入 — 評估套件僅包含邏輯 |
| 跳過待處理功能(失去可見性) | 新增為 pass: null,並附上解釋缺少內容的詳細資訊 |
| 匯入生產程式碼 | 完全複製 — Code 節點不共享作用域 |
| 一個巨大的測試函式 | 每個斷言使用一個 assert() — 產生可讀的報告 |
| 錯誤修復後刪除測試 | 永遠保留 — 這就是回歸防護 |
| 僅測試快樂路徑 | 拒絕路徑覆蓋是價值最高的測試 |
| 僅在變更後執行 | 在變更前 AND 後執行 — 確認你沒有引入回歸問題 |
| 在工作流程 JSON 中硬編碼聊天 ID / Token | 使用 $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