Claude Media
Claude CodeでGASをTypeScript化 — Jestのテストとclasp push前のhooks

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. 1

    src/ にTypeScriptを書く

    ロジックはsrc/lib/、GAS APIに触る入口はsrc/main.tsに置きます。

  2. 2

    Jestとtscで検査する

    テストと型検査が通らない限り先へ進めません。

  3. 3

    バンドラーでbuild/main.jsを作る

    .claspignoreでbuild/main.jsとappsscript.jsonだけを送る設定にします。

  4. 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 0

PostToolUseで終了コード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にテストを足すところから始めるなら、挙動を固定するテストを先に置く特性テストの書き方が使えます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. github.com/google/clasp
  3. jestjs.io/docs/getting-started
  4. jestjs.io/docs/mock-functions
この記事を共有:XはてブLinkedIn