Claude CodeでGASをTypeScript化 — Jestのテストとclasp push前のhooks
GASのロジックをSpreadsheetAppから切り離してTypeScriptで書き、Jestでテストします。clasp pushの前にhooksで全テストを通す設定も示します。
GASをTypeScriptで書き、Jestでテストするコツは、SpreadsheetAppに触る層と計算だけの層を別ファイルに分けることです。計算側は普通のTypeScriptなのでJestがそのまま走ります。Claude Codeには、編集のたびにテストを回すPostToolUse hookと、clasp pushの前に全検査を通すPreToolUse hookを持たせます。テストが赤いままGoogleのサーバーへ上がる事故を、手前で止められます。
clasp自体の準備はClaude CodeでGAS開発を進める手順にあります。ここでは、その先のテストとガードに絞ります。
先にclaspとTypeScriptの関係を押さえる
clasp(Apps ScriptのCLI)のREADMEには「Drop typescript support」という節があります。claspはTypeScriptを変換しなくなりました。TypeScriptのプロジェクトは、Rollupなどのバンドラーで変換してからclasp pushする前提です。
つまり構成は次の3段になります。
TypeScriptのGASが本番に届くまで
- 1
src/ にTypeScriptを書く
ロジックは
src/lib/、GAS APIに触る入口はsrc/main.tsに置きます。 - 2
Jestとtscで検査する
テストと型検査が通らない限り先へ進めません。
- 3
バンドラーでbuild/main.jsを作る
.claspignoreでbuild/main.jsとappsscript.jsonだけを送る設定にします。 - 4
clasp pushで反映する
pushはリモートのプロジェクトを送ったファイルで置き換えます。
READMEには、**/**で全部を除外して!build/main.jsと!appsscript.jsonだけを許可する.claspignoreのサンプルがあります。.claspignoreを置かない場合の既定は、appsscript.jsonと、rootDir配下のJavaScript・TypeScript・.htmlのソースを対象にするものです。test/のTypeScriptまで送られかねないので、TypeScriptのソースやテストをApps Script側に送らないために、この形が使えます。バンドラーの設定は、READMEが挙げるテンプレートのリポジトリ(google/asideなど)を参考にすると、関数をグローバルに残す手順まで含めて確認できます。
SpreadsheetAppを触る層と計算の層を分ける
GASのテストが難しい原因は、SpreadsheetAppやGmailAppがGoogleのサーバー上にしか存在しないことです。JestはローカルのNode.jsで動くので、これらを直接呼ぶコードはテストできません。
そこで、シートから値を読む部分と、値を計算する部分を分けます。計算側は引数で受けて値を返すだけの関数にします。
// src/lib/invoice.ts GASのAPIを一切使わない
export type Row = { name: string; qty: number; unitPrice: number };
export function summarize(rows: Row[]): { total: number; skipped: number } {
let total = 0;
let skipped = 0;
for (const r of rows) {
if (!r.name || !Number.isFinite(r.qty) || !Number.isFinite(r.unitPrice)) {
skipped++;
continue;
}
total += r.qty * r.unitPrice;
}
return { total, skipped };
}
export function toRows(values: unknown[][]): Row[] {
return values.map(([name, qty, unitPrice]) => ({
name: String(name ?? ''),
qty: Number(qty),
unitPrice: Number(unitPrice),
}));
}入口側は、GAS APIの呼び出しと上の関数の接続だけにします。
// src/main.ts SpreadsheetApp に触るのはここだけ
import { summarize, toRows } from './lib/invoice';
export function writeTotal(): void {
const sheet = SpreadsheetApp.getActiveSheet();
const last = sheet.getLastRow();
if (last < 2) return;
const values = sheet.getRange(2, 1, last - 1, 3).getValues();
const { total, skipped } = summarize(toRows(values));
sheet.getRange('E1').setValue(total);
sheet.getRange('E2').setValue(skipped);
}SpreadsheetAppなどの型は、DefinitelyTypedの@types/google-apps-scriptから入ります。tsconfig.jsonのtypesにgoogle-apps-scriptを入れると、グローバルのSpreadsheetAppが型付きで見えます。
JestでTypeScriptを動かす設定
JestのGetting Startedには、TypeScriptを動かす方法が2つ載っています。
- Babel経由:
babel-jest・@babel/preset-env・@babel/preset-typescriptを使う。型検査はしないのでtscを別に走らせる - Node.jsの型除去:
transformを空にする。Node.js^22.13.0か>=23.2.0が必要で、enumは使えない。この方式ではjest.mock()がimportより前に巻き上げられず、型だけを取り込むときはimport typeと書く必要がある
ここではBabel経由で組みます。
npm install --save-dev jest babel-jest @babel/core \
@babel/preset-env @babel/preset-typescript \
typescript @types/google-apps-script @types/jest// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};{
"compilerOptions": {
"target": "ES2019",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["google-apps-script", "jest"]
},
"include": ["src", "test"]
}skipLibCheckは省けません。手元の環境(Jest 30.5.2・TypeScript 7.0.2・@types/google-apps-scriptの最新)で外してtsc --noEmitを走らせると、型定義のMimeTypeの宣言が重複しているというエラー(TS2403)が出ました。自分のコードの誤りではなく、型定義ファイル側の話です。
テストを書く — SpreadsheetAppはjest.fn()で差し替える
計算の層は、引数を渡して戻り値を比べるだけです。
// test/invoice.test.ts
import { summarize, toRows } from '../src/lib/invoice';
test('空欄の行は合計に入れず skipped に数える', () => {
const rows = toRows([
['りんご', 3, 100],
['', 1, 50],
['みかん', '', 80],
]);
expect(summarize(rows)).toEqual({ total: 300, skipped: 2 });
});入口の層は、グローバルのSpreadsheetAppをjest.fn()で作った偽物に置き換えます。jest.fn()は呼び出しの記録と戻り値の差し替えができるモック関数で、toHaveBeenCalledWithで渡された値を検証できます。
// test/main.test.ts
import { writeTotal } from '../src/main';
test('writeTotal がシートの E1 に合計を書く', () => {
const setValue = jest.fn();
const range = { getValues: () => [['りんご', 3, 100]], setValue };
const sheet = { getLastRow: () => 2, getRange: jest.fn(() => range) };
(globalThis as any).SpreadsheetApp = { getActiveSheet: () => sheet };
writeTotal();
expect(setValue).toHaveBeenCalledWith(300);
});入口のテストは「正しいセルに正しい値を渡したか」までを見ます。GAS側の本当の挙動は、本番のシートでしか確かめられません。モックは実物と食い違えるので、入口は薄く保つほど安全です。
初回のテストが落ちる理由
上のコードをそのまま npx jest に通すと、invoice.test.tsが落ちます。
- Expected - 1
+ Received + 1
Object {
- "skipped": 2,
+ "skipped": 1,
"total": 300,
}原因はNumber('')が0になることです。シートの空セルはgetValues()で空文字として返るため、数量が空欄の「みかん」は有限の数と判定され、0 * 80で合計に入ってしまいます。直す場所は変換の関数です。
// 空セルは getValues() で '' になり、Number('') は 0 になってしまう
function toNumber(v: unknown): number {
return v === '' || v == null ? NaN : Number(v);
}
// toRows の中で qty: toNumber(qty), unitPrice: toNumber(unitPrice) に差し替えるこの種の不具合は、シートを開いて目で確かめても見落としやすいものです。テストに「空欄の行」を入れておくと、Claudeが後から関数を書き換えたときにも同じ場所で止まります。
PostToolUseで編集のたびに関連テストを走らせる
LWCのJestをhooksで回す設定と同じ形で、src/とtest/のTypeScriptが変わったら関連テストだけを実行します。jest --findRelatedTestsは、渡したソースファイルをカバーするテストだけを走らせるオプションです。
#!/bin/bash
# .claude/hooks/gas-jest.sh
# PostToolUse: src/ か test/ の .ts が変わったら関連する Jest テストだけ実行する
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in
*/src/*.ts|*/test/*.ts) ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
out=$(npx jest --findRelatedTests "$f" --passWithNoTests 2>&1)
code=$?
if [ "$code" -ne 0 ]; then
echo "Jest が失敗しました ($f)" >&2
echo "$out" | tail -n 60 >&2
exit 2
fi
exit 0PostToolUseで終了コード2を返すと、標準エラーがClaudeに渡されます。ツールはすでに実行済みなので編集は取り消されません。失敗を見せて、次のターンで直させるための終了コードです。Jestは失敗時に1を返すため、スクリプトで2に読み替えています。
先ほどのtoNumberを元に戻して試すと、このスクリプトはExpected/Receivedの差分を付けて終了コード2で終わりました。
PreToolUseでclasp pushの前に全検査を通す
編集のたびに回すのは関連テストだけです。反映の直前には、全テスト・型検査・ビルドを通します。PreToolUseは終了コード2でツール呼び出しを止められるイベントなので、Bashのclasp pushを差し止める用途に合います。
#!/bin/bash
# .claude/hooks/pre-clasp-push.sh
# PreToolUse(Bash): clasp push の前にテスト・型検査・ビルドを通す
cmd=$(jq -r '.tool_input.command // empty')
case "$cmd" in
*"clasp push"*) ;;
*) exit 0 ;;
esac
cd "$CLAUDE_PROJECT_DIR" || exit 0
for step in "npx jest" "npx tsc --noEmit" "npm run build"; do
out=$($step 2>&1)
if [ $? -ne 0 ]; then
echo "clasp push を止めました: '$step' が失敗しています" >&2
echo "$out" | tail -n 40 >&2
exit 2
fi
done
exit 0止められたときは、標準エラーの内容がツールのエラーとしてClaudeに渡されます。リファレンスの例ではPreToolUse:Bash hook error: [...]: Blocked: ...の形で届きます。
npm run buildは、手元のバンドラーを呼ぶスクリプトに置き換えてください。コマンドの絞り込みをスクリプト内のcaseで行っているのは、Bashのコマンドがclasp pushだけでなくnpm test && clasp pushのように連結されることもあるためです。*"clasp push"*なら部分一致で拾えます。
settings.jsonには、この2本を登録します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/gas-jest.sh",
"timeout": 120
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pre-clasp-push.sh",
"timeout": 300
}
]
}
]
}
}${CLAUDE_PROJECT_DIR}はセッションを始めたプロジェクトのルートを指します。timeoutは秒単位です。実行権限(chmod +x .claude/hooks/*.sh)を付け忘れると動かないので、先に付けておきます。
hookを通らないpushがある点
このガードが効くのは、Claude Codeが実行するBashのclasp pushだけです。自分でターミナルから打つpushや、clasp push --watchを別窓で常駐させている場合は素通りします。--watchはREADMEの説明で「ローカルの変更を監視し、数秒おきにファイルをpushする」オプションで、検査なしでpushが走ります。ガードを意味のあるものにしたいなら、watchは使わせない運用にします。
つまずきやすい4点
グローバルに差し込んだモックが次のテストに残る
globalThis.SpreadsheetAppに代入した偽物は、同じテストファイルの中では次のテストにも残ります。beforeEachで毎回作り直すか、afterEachでdeleteします。jest.fn()の状態を毎回リセットしたいときは、設定のresetMocksをtrueにします。restoreMocksは初期の実装まで戻す設定です。
日付とタイムゾーンはロジックに引数で渡す
GASはUtilities.formatDateでタイムゾーンを指定して日付を整形します。この関数はJestの環境に存在しません。計算の層にDateと文字列で渡す形にしておけば、テストでは固定の日付を渡せます。入口の層でUtilities.formatDateを呼ぶ構成にして、テストでは差し替えます。
バンドル後に入口の関数が消える
src/main.tsのexport function writeTotal()は、バンドラーの設定によってはbuild/main.jsの中で関数の宣言として残らず、トリガーやメニューから呼べなくなります。Jestは通るのに本番だけ動かない型の不具合です。build/main.jsにfunction writeTotalが残っているかを、ビルドの最後にgrepで確かめる1行をpre-pushスクリプトに足しておくと防げます。
パスのエイリアスを使うならJest側にも写す
tsconfig.jsonのpathsで@lib/*のような別名を付けると、tscは通ってもJestは解決できません。JestのmoduleNameMapperに同じ対応を書きます。値には<rootDir>のトークンが使えます。
// jest.config.js
module.exports = {
moduleNameMapper: {
'^@lib/(.*)$': '<rootDir>/src/lib/$1',
},
};CLAUDE.mdに書いておく約束
hookは失敗を伝える仕組みで、構成そのものはClaudeが覚えておく必要があります。CLAUDE.mdには次の程度を書いておくと、SpreadsheetAppを計算のコードに混ぜられる事態を減らせます。
## GAS プロジェクトの構成
- `src/lib/` は純粋な関数だけ。`SpreadsheetApp` `GmailApp` `UrlFetchApp` などのGAS APIを import も参照もしない
- GAS API を呼ぶのは `src/main.ts` だけ。ここは薄く保ち、判断は `src/lib/` に置く
- ロジックを変えたら `test/` にテストを足す。空セル('')と日付・数値の変換を必ず1件入れる
- 反映は `npm run build` の後に `clasp push`。pushの前に hook が検査する実行時間の制限などGAS固有の制約は、GASの6分制限を分割実行に直す記事のように別に詰める論点です。外部APIを呼ぶ入口なら、UrlFetchAppの差し替えもjest.fn()で同じ要領でできます。呼び出し側の実装はGASからClaude APIを呼ぶ記事を参照してください。
まとめ
GASのテストが難しいのは、GAS APIへの依存がロジックに混ざっているときだけです。分けてしまえば、Jestの普通のテストになります。分け方さえ守らせれば、あとはhookが見張ります。
最初に書かせるテストは、シートの値の変換です。空セルが''で届くという点だけで、今回のように合計がずれました。テストなしでClaudeにGASを書かせると、こうしたずれがそのままclasp pushされます。既存のGASにテストを足すところから始めるなら、挙動を固定するテストを先に置く特性テストの書き方が使えます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。