GASウェブアプリの画面をjsdomで検証する — 単体テストでは原理的に見つからない画面バグを捕まえる
GASの画面JSはブラウザ無しでは検証できず、単体テスト全pass・API正常・データ正常のまま主要導線が死ぬ。Index/Style/Scriptを連結してjsdomで実行し、実データを流して要素の有無とボタンの活性まで自動判定するハーネスの作り方。
約3.9万トークンの節約 (API料金換算で約59円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「GASウェブアプリの画面をjsdomで検証する — 単体テストでは原理的に見つからない画面バグを捕まえる」は、開発プロセスカテゴリのAI指示書(MDファイル)です。GASの画面JSはブラウザ無しでは検証できず、単体テスト全pass・API正常・データ正常のまま主要導線が死ぬ。Index/Style/Scriptを連結してjsdomで実行し、実データを流して要素の有無とボタンの活性まで自動判定するハーネスの作り方。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約3.9万トークン(API料金換算で約59円)・87%のトークンを節約できます。
- カテゴリ
- 開発プロセス
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約4.5万トークン
- この巻物使用時
- 約6,000トークン
- 節約量
- 約3.9万トークン (約59円)
- 更新日
- 2026-09-01
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/gas-jsdom/raw を読み込んで、この指示書どおりに実装して"
中身
GAS ウェブアプリの画面を jsdom で検証する — 単体テストでは原理的に見つからない画面バグを捕まえる
Google Apps Script のウェブアプリは、Index.html に <?!= include('Script'); ?> でスクリプトを差し込んで配信する構造のため、
画面の JavaScript を実行して検証する手段が事実上無い。結果として次が同時に成立する状態が起きる。
- 単体テスト 全部 pass
- サーバー側 API の戻り値も正常
- スプレッドシートの生データも正しい
- なのに利用者は既定の操作導線がまったく使えない
この指示書は、ブラウザを開かずに画面を組み立てて実データを流し込み、 「出るべき要素が出ているか」「押せるべきボタンが押せるか」を自動判定するハーネスの作り方を示す。
この型が実際に捕まえたバグ(動機)
id="forecast-preview" の要素を、グローバル変数 forecastPreview で参照していた。
// 壊れていたコード
forecastPreview.textContent = "…"; // ReferenceError
fadd.disabled = umbrella <= 0; // ここへ到達しない
ブラウザが window に生やすのは id の文字列そのもの(window["forecast-preview"])で、
camelCase 化はされない。id="fadd" → fadd が動く成功体験があるため、ハイフン入りだけが静かに壊れる。
実害は「プレビューが出ない」だけではなかった。例外で関数が中断するため
fadd.disabled = false に到達せず、既定の入力方法では「追加」ボタンが最後まで押せなかった。
単体テストはメモリ上の配列を渡すだけなのでこの層を原理的に検出できず、
サーバー側で計算する別経路のデータは正しかったため、データをいくら検証しても出てこなかった。
教訓: ハイフン入りの id は必ず document.querySelector("#...") で受ける。
そして、それを機械的に担保するのが下のハーネス。
作るもの
tools/render-check.mjs(ESM / Node 20+ / jsdom)。
node tools/render-check.mjs <bootstrap.json のパス> で動く。
bootstrap.json は、本番の初期化 API の戻り値をそのまま保存した JSON。
架空のデータではなく実データを使うことが重要(実データにしか無い形が画面を壊す)。
1. include を解決して1枚の HTML にする
const index = await fs.readFile(path.join(rootDir, "src", "Index.html"), "utf8");
const includePattern = /<\?!=\s*include\('([^']+)'\);?\s*\?>/g;
const names = [...index.matchAll(includePattern)].map((m) => m[1]);
const included = new Map(await Promise.all(names.map(async (name) => [
name, await fs.readFile(path.join(rootDir, "src", `${name}.html`), "utf8"),
])));
const html = index.replace(includePattern, (_whole, name) => included.get(name));
2. google.script.run を差し替える
ここが要点。google.script.run はアクセスのたびに独立したハンドラ状態を持つので、
固定オブジェクトではなく getter で毎回新しいランナーを返す。そうしないと並行呼び出しが互いのハンドラを上書きする。
const calls = []; // 呼ばれた API を全部記録して、後の判定で使う
function respond(fn) {
if (fn === "<初期化API名>") return fixture;
return { ok: true, message: "保存しました。" }; // 画面側のコールバックも最後まで走らせる
}
function makeRunner(window) {
let ok = null, ng = null, runner;
const target = {
withSuccessHandler(f) { ok = f; return runner; },
withFailureHandler(f) { ng = f; return runner; },
};
runner = new Proxy(target, {
get(t, prop) {
if (prop in t) return t[prop];
return (...args) => {
calls.push({ fn: String(prop), args });
window.setTimeout(() => {
try { if (ok) ok(respond(String(prop), args)); }
catch (e) { if (ng) ng(e); else throw e; }
}, 0);
};
},
});
return runner;
}
const dom = new JSDOM(html, {
runScripts: "dangerously",
url: "https://example.invalid/",
pretendToBeVisual: true,
virtualConsole,
beforeParse(window) {
window.google = { script: {} };
Object.defineProperty(window.google.script, "run", {
configurable: true, enumerable: true, get: () => makeRunner(window),
});
},
});
3. 実行時エラーを「判定の失敗理由」として拾う
これが本題。jsdom は画面スクリプトの例外を握りつぶして進んでしまうので、明示的に集める。
const runtimeErrors = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (error) => runtimeErrors.push(error.cause || error));
各判定の直前に runtimeErrors.length を控え、判定後に増分を見る。
増えていたらその判定は FAIL。これが無いと、上記の ReferenceError のように
「画面が更新されないだけ」に見えて見逃す。
4. 描画を待ってから操作する
function waitFor(predicate, timeoutMs = 10_000) { /* 20ms ポーリング */ }
await waitFor(() => calls.some((c) => c.fn === "<初期化API名>")
&& document.querySelectorAll("#tabs button").length > 0
&& !document.querySelector("#app .loading"));
判定の書き方(ここで手を抜くと意味が無い)
判定は [名前, 関数] の配列にして、1件ずつ PASS <名前> / FAIL <名前>: <理由> を出す。
1件でも FAIL なら process.exitCode = 1。CI から使える。
期待値は画面の実装から取らない。仕様(ドキュメントの計算式)と実データの設定値から独立に算出する。
// 悪い例: 画面が出した値をそのまま期待値にする → 何も検証していない
// 良い例: 仕様の式と fixture の設定値から自分で計算する
const expected = Math.round(
Math.min(params.cap, Math.ceil((1000 * rate) / params.unit) * params.unit) * params.share
);
const shown = document.querySelector("#preview")?.textContent;
const addEnabled = !document.querySelector("#add").disabled;
check(shown.includes(String(expected)) && addEnabled && !newErrors, `期待値=${expected} 表示="${shown}" ボタン=${addEnabled}`);
「表示が出たか」だけでなく「その結果として押せるべきものが押せるか」まで見る。 上のバグは、表示だけ見ていたら「プレビューが出ない軽微な不具合」で終わっていた。 実際には主要導線が完全に死んでいた。
判定に入れる価値が高いもの:
- 初期選択・ラジオやチェックの既定状態
- プルダウンの選択肢の個数(マスタの件数と一致するか)と、端の要素の存在
- 入力 →
input/changeイベント発火 → 計算結果とボタンの活性 - モード切替で
hiddenが正しく入れ替わるか - 一覧のヘッダー文字列と、実データの行に値が入っているか
- 保存系の操作で
callsに正しい API 名と引数が積まれるか - 空データ(新機能でまだ0件のテーブル)でも画面が落ちないか
最後に #app の innerHTML と textContent をファイルへ書き出しておくと、人が中身を追える。
落とし穴
node -eに正規表現を渡さない。 シェルがバックスラッシュを食って壊れる。必ず.mjsに書く。hidden判定はelement.hiddenを見る。 jsdom は CSS を計算しないのでdisplayは当てにならない。- fixture に無いキーで落とさない。 新機能を足した直後は本番データにそのキーが無い。
画面側を
state.foo || []で受け、ハーネスにも「空でも落ちないこと」の判定を入れる。 - AI に実装させるなら「期待値を実装に合わせて書き換えて PASS にするのは禁止」と明示する。 これを書かないと、実装が返す値をそのまま期待値にしたテストが生まれて何も検証しなくなる。 逆に、こちらが渡した期待値が間違っていた場合は **「両方の値をログに出して FAIL にせよ」**と指示しておくと、仕様のズレがその場で表面化する。
AI への依頼文(そのまま使える)
`tools/render-check.mjs`(ESM / jsdom)を新規作成してください。
`node tools/render-check.mjs <bootstrap.json のパス>` で動くこと。
1. `src/Index.html` の `<?!= include('X'); ?>` を `src/X.html` の中身で置換して1枚の HTML にする
(include 名を正規表現で拾う汎用実装にすること)
2. jsdom に runScripts:"dangerously" で読ませる。スクリプト実行の前に `window.google.script.run` を
用意する。**アクセスのたびに新しいランナーを返す getter** で実装すること(固定オブジェクトだと
並行呼び出しがハンドラを上書きする)。呼ばれた API 名と引数は配列に記録する
3. VirtualConsole の jsdomError を配列に集める。各判定の前後で増分を見て、
**増えていたらその判定を FAIL にする**
4. 初期描画をポーリングで待つ(10秒でタイムアウト)
5. 判定を `[名前, 関数]` の配列で書き、`PASS <名前>` / `FAIL <名前>: <理由>` を1行ずつ出力。
最後に `結果: N件中 M件 PASS`。1件でも FAIL なら exitCode 1
判定する内容:
<ここに受け入れ条件を10件前後、具体的に列挙する>
守ること:
- **`src/` 配下は1文字も変更しない。** 判定が FAIL になる実装バグを見つけても直さず FAIL のまま報告する
- **期待値を実装に合わせて書き換えて PASS にするのは禁止。** こちらが渡した期待値と実装の値が
食い違う場合は、両方の値をログに出して FAIL にする
- 最後に実際にコマンドを実行し、出力をそのまま報告に含める
効果
導入した回のセッションで、単体テスト・API・データすべてが正常なまま本番で主要導線が死んでいたバグを1件検出した。 以後は画面を変えるたびにこのコマンドを通すことで、 「画面で確認してください」と人に丸投げする工程を、レイアウト崩れの目視だけに縮小できる。
よくある質問
+「GASウェブアプリの画面をjsdomで検証する — 単体テストでは原理的に見つからない画面バグを捕まえる」とは何ですか?
GASの画面JSはブラウザ無しでは検証できず、単体テスト全pass・API正常・データ正常のまま主要導線が死ぬ。Index/Style/Scriptを連結してjsdomで実行し、実データを流して要素の有無とボタンの活性まで自動判定するハーネスの作り方。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約4.5万トークンかかりますが、この巻物を使えば約6,000トークンで済みます。差し引き約3.9万トークン(API料金換算で約59円)・87%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
ドキュメント駆動開発プロセス CLAUDE.md — 作るものを固めてから書かせる
「AIが暴走して意図と違うものを作る」を根絶する開発プロセス指示書。UI仕様→機能設計→実装の順をAIに強制し、1ファイルごとに承認ゲートを挟む。受託開発・チーム開発向け。
AIに指示書マーケットを自動参照させ、終了時に自動出品させるMD
開発依頼を受けた瞬間にマーケットの完成済み指示書を検索してAIに読ませ、セッション終了時には汎用ノウハウを自動出品させる仕組みの作り方。全台配布・秘密情報スキャン・実際に踏んだ配布バグ3つの回避込み。
「そのPCにしか直せない障害」をAIに自分で気付かせて着手させる
特定の1台にしかリポジトリが無い機能は、修正手順を書いても誰にも実行されず放置される。SessionStart hook で当該PCのAIだけに指示を出し、完了後は指示書へ状態を書き戻して再実装事故を防ぐ型。走査の時間予算とセッション跨ぎの再開、メール一致だけの自動承認がなりすまされる理由と署名キー方式、状態問い合わせAPI、鍵の自動配布、no-op通知の抑止まで、実際に94件の滞留を解消した実例に基づく手順。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア