n8n Evaluations Runner(n8n評価ランナー)
トリガーするタイミング
- フィルターロジック、キーワードリスト、バリデーター、レジュームマップを追加または変更する場合
- Codeノードの純粋関数ロジックをリファクタリングする場合
- ワークフローの変更をマージする前 — リグレッションガード
- 本番インシデント後 — 障害をテストとしてコード化する場合
- 「n8nコードを単体テストするには?」/ 「APIクレジットを使わずにテストするには?」
これの概要(そうでないものとの違い)
| これが該当するもの | これが該当しないもの |
|---|---|
| モック入力を使用したロジックのみのn8nワークフロー | ライブAPIに対する統合テスト |
| 最終レポート送信以外の外部呼び出しなし — 約3秒で実行 | パフォーマンステスト |
| Telegramにプッシュされる合格/不合格/保留中のカウント | エンドツーエンドフローの手動スモークテストの代わり |
| Codeノード内の純粋JSに対するリグレッション網 | HTTP / Gmail / Telegramノードの動作のカバレッジ |
コスト: APIクレジット不要。モック入力、メモリ内のみ。Telegramレポートが唯一の外部呼び出しです。
アーキテクチャ
[手動トリガー] → [全テストを実行(Codeノード)] → [レポートを整形(Codeノード)] → [レポートを送信(Telegram)]
ロジックサーフェスごとに1つのワークフロー。オンデマンドで再実行可能。
標準テストランナー Codeノード
const results = [];
function assert(suite, testName, condition, detail) {
results.push({ suite, test: testName, pass: condition, detail: detail || '' });
}
// ============================================================
// Suite 1 — <名前>
// ============================================================
// 本番関数をそのままここにコピー(インポート不可 — Codeノードはスコープを共有しない)
function passesJs10Filter(job) {
// ... 本番ノードと同じコード ...
}
// テスト
assert('js-10', 'クリアランスジョブは却下',
!passesJs10Filter({
title: 'テクニカルライター',
description: 'クリアランスが必要です。',
location: 'リモート'
}),
'クリアランス必須は却下をトリガーする必要があります');
assert('js-10', '有効なリモートテクニカルライターは通過',
passesJs10Filter({
title: 'テクニカルライター',
description: 'クラウドプラットフォームのAPIドキュメントを作成します。',
location: 'リモート'
}),
'有効なリモートテクニカルライターはすべてのフィルターを通過する必要があります');
// 未実装機能の場合:
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'、型違い → 期待されるデフォルト - 既知の正常実データ — 過去の動作確認済み実行から少なくとも1つの例
- 既知の異常実データ — 過去のインシデントを引き起こした少なくとも1つの例
- 部分一致vs完全一致マッチャー — 例:
'HR Dept'vs'Human Resources Department' - 保留中の機能 —
pass: nullとして記述し、レポートにバックログとして表示させる
テスト命名規則
<スイート>: <人間が読めるアサーション> 詳細フィールドには、基になるルールや過去のバグ参照を記載:
assert('js-22', '人事部は却下',
!isValidContact('人事部'),
'BAD_CONTAINS_CONTACT部分一致マッチャーはこれをキャッチする必要があります(Session 5のバグ修正)');
「過去のバグをテストに変換する」ルール
学びのライブラリ(Lessons Learned Library)にある、特定の入力 → 誤った動作を名前付けするエントリはすべて、記述されるべきテストです。
実際の教訓からの例:
「集計セクタークォートは、1日変化ではなく1ヶ月変化を計算していました。これはmeta.previousCloseがrange=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ノードはスコープを共有しない |
| 1つの巨大なテスト関数 | アサーションごとに1つのassert() — 読みやすいレポート |
| バグ修正後にテストを削除する | 永久に保持 — それがリグレッションガード |
| 正常系パスのみテストする | 却下パスカバレッジが最も価値の高いテスト |
| 変更後にのみ実行する | 変更前と後の両方で実行 — リグレッションを導入していないことを確認 |
| chat ID / トークンをワークフローJSONにハードコードする | $env.TELEGRAM_BOT_TOKEN / $env.TELEGRAM_CHAT_ID を使用する |
デプロイ前チェックリスト
- 評価ワークフローが10秒未満で実行される
- すべてのスイートがレポートにグループ化されている
- 本番コードがそのままコピーされている(言い換えではない)
- 却下パスごとに少なくとも1つのテストがある
- スイートごとに少なくとも1つの「既知の正常実データ」テストがある
- 保留中の機能は
pass: nullと詳細で表現されている - レポートが1つのTelegramメッセージに収まる(4000文字未満)
参考
- n8n Codeノードドキュメント: https://docs.n8n.io/code/code-node/
- Telegram Bot APIフォーマット: https://core.telegram.org/bots/api#formatting-options