台本のクライアントでテストする
台本のクライアントは、決まったメッセージのリストをサーバーに送り、すべての応答を確かめる短いプログラムです。疲れず、段を飛ばしません。
サーバーは、うっかり壊れやすいものです。一文字の編集で応答が変わっても、本物のクライアントが失敗するまで、誰も気づきません。
このレッスンのゴール
メモ
台本のクライアントで、サーバーをテストできる。
キーワード
| ことば | いみ | れい |
|---|---|---|
| 台本 | 送るメッセージと、期待する応答の組のリスト | |
| 期待 | テストが求める応答 | |
| 実際 | サーバーが本当に送った応答 | |
| 違い | 二つが最初に合わない場所 | |
| ワイルドカード | どの値にも合う印 | <any> |
学生が入力するセルは、このプレビューでは空白です。
台本は、メッセージと応答を組にする
台本は、段のリストです。各段には、送るメッセージと、期待する応答があります。通知の段は、応答がないことを期待します。
バージョン番号のように、変わる値もあります。テストはそこにワイルドカードを付け、そこが変わっても失敗にしません。ほかはすべて、正確に合わなければなりません。
例: 足し算ツールの台本
| 段 | 送るもの | 期待する応答 |
|---|---|---|
| 1 | initialize | 求めた版、tools を知らせる、サーバーの版は何でもよい |
| 2 | 準備完了の通知 | 応答なし |
| 3 | tools/list | add という名前のツールが一つ |
| 4 | add 2 と 3 | テキスト 5、isError は false |
| 5 | add "2" と 3 | ツールのエラー、isError は true |
学生が入力するセルは、このプレビューでは空白です。
最初の違いを、場所とともに報告する
「失敗」としか言わないテストは、時間を無駄にします。良いテストは場所を言います。パス result.content[0].text は、応答の中の一つの値を指します。
チェッカーは、期待した応答を決まった順にたどり、最初の違いで止まります。何を期待し、何を得たかを述べます。
よくある間違い
注意
正しく動くサーバーにだけテストを走らせる人は多いです。一度も失敗したことのないテストは、何かを捕まえられると示されたことがありません。サーバーをわざと壊し、テストが失敗するかを確かめます。そのあと、壊したのを元に戻します。
モデル用の補助を書く
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' ' if (res.ok === false) throw new Error(\"status \" + res.status);' ' const data = await res.json();' ' return { text: data.choices[0].message.content, recorded: false };' ' } catch (error) {' ' console.log(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' return { text: sample, recorded: true };' ' }' '}' 'module.exports = { ask };' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'const SCHEMA = { type: \"object\", properties: { a: { type: \"integer\" }, b: { type: \"integer\" } }, required: [\"a\", \"b\"] };' 'function words(text, bad) { return { content: [{ type: \"text\", text: text }], isError: bad }; }' > server6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' const args = p.arguments || {};' ' if (msg.method === \"initialize\") return send({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"adder\", version: \"1.0\" } } });' >> server6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' if (msg.method === \"tools/list\") return send({ id: msg.id, result: { tools: [{ name: \"add\", description: \"整数を二つ足す\", inputSchema: SCHEMA }] } });' ' if (msg.method === \"tools/call\" && p.name === \"add\") {' ' if (Number.isInteger(args.a) && Number.isInteger(args.b)) return send({ id: msg.id, result: words(String(args.a + args.b), false) });' ' return send({ id: msg.id, result: words(\"a and b must be whole numbers\", true) });' ' }' ' if (msg.method === \"tools/call\") return send({ id: msg.id, error: { code: -32602, message: \"Unknown tool: \" + p.name } });' ' send({ id: msg.id, error: { code: -32601, message: \"Method not found: \" + msg.method } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) { handle(JSON.parse(line)); });' >> server6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(1)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const server = require(\"child_process\").spawn(\"node\", [process.argv[2] || \"server6.js\"]);' 'const any = \"<any>\";' 'const one = function (id, method, params) { return { jsonrpc: \"2.0\", id: id, method: method, params: params }; };' 'const add = function (id, args) { return one(id, \"tools/call\", { name: \"add\", arguments: args }); };' 'const ok = function (id, result) { return { jsonrpc: \"2.0\", id: id, result: result }; };' 'const text = function (t, bad) { return { content: [{ type: \"text\", text: t }], isError: bad }; };' > check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(2)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const steps = [' ' [one(1, \"initialize\", { protocolVersion: \"2025-03-26\" }), ok(1, { protocolVersion: \"2025-03-26\", capabilities: { tools: {} }, serverInfo: { name: \"adder\", version: any } })],' ' [{ jsonrpc: \"2.0\", method: \"notifications/initialized\" }, null],' ' [one(2, \"tools/list\"), ok(2, { tools: [{ name: \"add\", description: any, inputSchema: any }] })],' ' [add(3, { a: 2, b: 3 }), ok(3, text(\"5\", false))],' ' [add(4, { a: \"2\", b: 3 }), ok(4, text(\"a and b must be whole numbers\", true))],' ' [one(5, \"tools/call\", { name: \"nope\" }), { jsonrpc: \"2.0\", id: 5, error: { code: -32602, message: \"Unknown tool: nope\" } }],' '];' >> check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(3)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function isObj(x) { return Boolean(x) && typeof x === \"object\"; }' 'function name(path, k, list) { return list ? path + \"[\" + k + \"]\" : (path ? path + \".\" + k : k); }' 'function diff(want, got, path) {' ' if (want === any) return null;' ' const both = isObj(want) && isObj(got) && Array.isArray(want) === Array.isArray(got);' ' const said = \" expected \" + JSON.stringify(want) + \", got \" + JSON.stringify(got);' ' if (both === false) return JSON.stringify(want) === JSON.stringify(got) ? null : (path || \"(root)\") + \":\" + said;' >> check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' const list = Array.isArray(want);' ' if (list && (want.length === got.length) === false) return (path || \"(root)\") + \": expected \" + want.length + \" items, got \" + got.length;' ' const keys = Object.keys(want).sort();' ' for (const k of keys) {' ' if ((k in got) === false) return name(path, k, list) + \": missing\";' ' const d = diff(want[k], got[k], name(path, k, list));' ' if (d) return d;' ' }' ' const extra = Object.keys(got).sort().filter(function (k) { return keys.indexOf(k) < 0; });' ' return extra.length > 0 ? name(path, extra[0], list) + \": unexpected\" : null;' '}' >> check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(5)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const lines = [];' 'const waiting = [];' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (l) {' ' if (waiting.length > 0) waiting.shift()(JSON.parse(l)); else lines.push(JSON.parse(l));' '});' 'function next() { return lines.length > 0 ? Promise.resolve(lines.shift()) : new Promise(function (r) { waiting.push(r); }); }' >> check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
チェッカーを書く(6)
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'async function main() {' ' let passed = 0, total = 0, first = null;' ' for (let i = 0; i < steps.length; i++) {' ' server.stdin.write(JSON.stringify(steps[i][0]) + \"\n\");' ' if (steps[i][1] === null) continue;' ' total += 1;' ' const d = diff(steps[i][1], await next(), \"\");' ' console.log((d ? \"FAIL step \" + (i + 1) + \" at \" + d : \"PASS step \" + (i + 1)));' ' if (d === null) passed += 1; else if (first === null) first = d;' ' }' ' server.stdin.end();' ' console.log(\"passed \" + passed + \" of \" + total);' ' if (first === null) return;' ' const r = await ask([{ role: \"user\", content: \"私のサーバーのテストが、次の違いで失敗しました。考えられる原因を一文で書いてください: \" + first }], \"サーバーが返した文字列が、テストの期待した文字列と違っています。\", 60);' ' console.log(\"モデル: \" + r.text);' '}' 'main();' >> check6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
良いサーバーにチェッカーを走らせる
docker exec -w /home/node/project agent node check6.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
悪い編集を入れた複製を作る
docker exec -w /home/node/project agent sh -c "sed 's/args.a + /args.a - /' server6.js > bad6.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
悪い複製にチェッカーを走らせる
docker exec -w /home/node/project agent node check6.js bad6.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
二つの Run を並べる
| Run | 確かめた段 | 結果 |
|---|---|---|
| 良いサーバー | 1、3、4、5、6 | passed 5 of 5 |
| 悪い複製 | 1、3、4、5、6 | passed 4 of 5 |
| 悪い複製、段 4 | add 2 と 3 | result.content[0].text: expected "5", got "-1" |
学生が入力するセルは、このプレビューでは空白です。