GASの業務ロジックをローカルでテストしたい ── GASの制約をOSSで越える
luth · 公開
こんにちは、luthです📝
この記事の内容
こんにちは、luthです📝
TypeScript + claspでGASをローカル管理している(あるいはこれから始めたい)方向けに、Vitestで業務ロジックのテストを自動化する方法を紹介します。
Vitestのようなテストフレームワークの本当の価値は、「一度書いたテストを、コードを変更するたびに何度でも自動で再実行し、意図しない振る舞いの変化(デグレ)を検知できる」という継続的な品質保証にあります。
自動テストが本当に効いてくるのは、 分岐が複雑でロジックの見通しが悪くなりがちな業務ロジック を、半年後・1年後に別の担当者(あるいは未来の自分)が触っても壊れていないことを機械的に保証したい場面です。
GASはスプレッドシートを介した業務システムの心臓部になっていることが多く、承認フローや集計ロジックのような「条件分岐が多く、かつ間違えると実害が出る」コードほど、この恩恵を受けます。
ただし、GASのコードをローカルでVitestにかけようとすると、2つの壁にぶつかります。
この記事では、その壁と、実際に使っている解決策を具体的な題材を通して紹介します。
壁1: GAS専用グローバルメソッド群が存在しない
まず、GASのコードは素のVitest(やJest)ではそもそも実行できません。
試しに、 SpreadsheetApp を使う関数をただのNode.jsスクリプトとして実行してみます。
function decideApprovalRoute() {
const sheet = SpreadsheetApp.getActive().getSheetByName("経費申請履歴");
return sheet;
}
decideApprovalRoute();
ReferenceError: SpreadsheetApp is not defined
at decideApprovalRoute (/demo.js:2:17)
SpreadsheetApp や CalendarApp 、 GmailApp といったGASのグローバルは、Google Apps Scriptのランタイム上で実行されたときにだけ注入されるものなので、Node.js環境にはそもそも存在しません。
つまりGASの業務ロジックをテストしようとした瞬間、ロジックの中身を検証する以前に「参照しているだけで即クラッシュする」という壁にぶつかります。
vitest-plugin-gas-mockで解決する
@ciderjs/vitest-plugin-gas-mockがやっているのは、 @types/google-apps-script の型定義を解析し、GASの全メソッドを あらかじめグローバルに vi.fn() としてモック注入しておく ことです。
npm install -D vitest @ciderjs/vitest-plugin-gas-mock
import { defineConfig } from "vitest/config";
import { mockGas } from "@ciderjs/vitest-plugin-gas-mock";
export default defineConfig({
plugins: [mockGas()],
test: {
globals: true, // グローバルへの注入に必要
clearMocks: true, // テスト間で呼び出し履歴を引きずらないようにする
},
});
これにより、ロジックの中で SpreadsheetApp や GmailApp をいくつ呼んでいようと、それだけでテストが落ちることはなくなります。
開発者は「このロジックが意図通りに分岐しているか」だけに集中できる 、というのがこのプラグインの一番の価値です。
その上で、ロジックの分岐がGASメソッドの戻り値に依存している場合(例:シートの値によって処理を変える、カレンダーの予定有無で処理を変える)は、 mockChain でその 特定のチェーンの終端だけ を狙って戻り値を差し替えます。
全部を手動でモックし直す必要はなく、「ロジックの分岐条件に関わる部分だけ」ピンポイントで制御できるのがポイントです。
import { mockChain } from "@ciderjs/vitest-plugin-gas-mock";
mockChain("SpreadsheetApp.getActive.getSheetByName.getDataRange.getValues", [
["u001", "2026-8", 12000],
]);
なお clearMocks: true を入れているのは、 mockChain が裏で操作しているのが通常の vi.fn() だからです。
これを入れておかないと、例えばテストAで toHaveBeenCalledTimes(1) のようなアサーションをした場合、テストBでの呼び出し回数まで引き継いでしまい、意図しない失敗(あるいは見逃し)につながることがあります。
壁2: export するとGASでは動かない
Vitestでテスト対象の関数をテストファイルから読み込むには、当然その関数を export しておく必要があります。
export function decideApprovalRoute(request: ExpenseRequest): ApprovalRoute {
// ...
}
ところが、 clasp push でそのままGASに上がるJavaScriptファイル (もしくは、TypeScriptをトランスパイルしたファイル) では、 export のようなESモジュール構文が残っていると、GAS側で対応しておらず、パースエラーになってしまいます。
つまり「Vitestでテストするには export が要る」「GASにデプロイするには export があってはいけない」という、そのままでは両立しない要件がぶつかります。
rolldown-plugin-remove-exportで解決する
この矛盾を解消するために作ったのがrolldown-plugin-remove-exportです。
ソースコード上はESモジュール構文通り export を書いたままにしておき、rolldown(またはrollup)でGAS用にバンドルするタイミングだけ、指定したファイルの export 句を機械的に取り除きます。
npm install -D rolldown rolldown-plugin-remove-export
import { defineConfig } from 'rolldown';
import { removeExportPlugin } from 'rolldown-plugin-remove-export';
export default defineConfig({
input: "src/index.ts", // すべてのGAS関数がエクスポートされているファイル
output: {
file: 'dist/out.js',
},
plugins: [removeExportPlugin("out.js")],
});
これで、ソースコードは export ありのまま普通にVitestでテストでき、 clasp push する成果物だけ export なしのGASが読める形に変換される、という流れが成立します。
題材: 経費申請の承認ルート判定
2つの壁が解決したところで、実際の業務ロジックをテストしてみます。
「金額」「費目」「申請者の当月累計」「承認者の休暇有無」で承認ルートが変わる、という典型的な業務ロジックを題材にします。
type ApprovalRoute = "auto" | "manager" | "manager_via_deputy" | "executive";
type ExpenseRequest = {
requesterId: string;
amount: number;
category: "交通費" | "交際費" | "備品" | "その他";
date: Date;
};
const AUTO_APPROVE_LIMIT = 10000;
const EXECUTIVE_APPROVAL_LIMIT = 100000;
const MONTHLY_BUDGET_CAP = 50000;
const ENTERTAINMENT_MANAGER_REVIEW_LIMIT = 30000;
export function decideApprovalRoute(request: ExpenseRequest): ApprovalRoute {
if (request.amount > EXECUTIVE_APPROVAL_LIMIT) {
return "executive";
}
if (request.category === "交際費" && request.amount > ENTERTAINMENT_MANAGER_REVIEW_LIMIT) {
return isManagerOnLeave(request.date) ? "manager_via_deputy" : "manager";
}
const monthlyTotal = getMonthlySpending(request.requesterId, request.date);
if (request.amount <= AUTO_APPROVE_LIMIT && monthlyTotal + request.amount <= MONTHLY_BUDGET_CAP) {
return "auto";
}
return isManagerOnLeave(request.date) ? "manager_via_deputy" : "manager";
}
function getMonthlySpending(requesterId: string, date: Date): number {
const sheet = SpreadsheetApp.getActive().getSheetByName("経費申請履歴");
if (!sheet) return 0;
// getValues() の実際の戻り値は any[][] なので、扱いやすいようにここでキャストしている
const values = sheet.getDataRange().getValues() as [string, string, number][];
const targetMonth = `${date.getFullYear()}-${date.getMonth() + 1}`;
return values
.filter(([id, ym]) => id === requesterId && ym === targetMonth)
.reduce((sum, [, , amount]) => sum + amount, 0);
}
function isManagerOnLeave(date: Date): boolean {
const events = CalendarApp.getDefaultCalendar().getEventsForDay(date);
return events.some((event) => event.getTitle() === "上長休暇");
}
分岐が5つ絡み合っていて、しかも2つは「スプレッドシートの実データ」「カレンダーの実データ」という、Web IDE上で毎回手で再現するのが地味に面倒な外部状態に依存しています。
ここが自動テストの効きどころです。
テストを書く
import { beforeEach, describe, expect, test } from "vitest";
import { mockChain } from "@ciderjs/vitest-plugin-gas-mock";
import { decideApprovalRoute } from "../src/expenseApproval";
describe("decideApprovalRoute", () => {
beforeEach(() => {
// デフォルトでは「履歴なし・上長は休暇していない」状態にしておく
mockChain("SpreadsheetApp.getActive.getSheetByName.getDataRange.getValues", [[]]);
mockChain("CalendarApp.getDefaultCalendar.getEventsForDay", []);
});
test("上限額を超える場合は決裁ルートになる", () => {
const route = decideApprovalRoute({
requesterId: "u001",
amount: 150000,
category: "備品",
date: new Date(2026, 7, 20),
});
expect(route).toBe("executive");
});
test("交際費が閾値を超え、上長が出社している場合は上長承認になる", () => {
const route = decideApprovalRoute({
requesterId: "u001",
amount: 40000,
category: "交際費",
date: new Date(2026, 7, 20),
});
expect(route).toBe("manager");
});
test("交際費が閾値を超え、上長が休暇中の場合は代理承認になる", () => {
mockChain("CalendarApp.getDefaultCalendar.getEventsForDay", [{ getTitle: () => "上長休暇" }]);
const route = decideApprovalRoute({
requesterId: "u001",
amount: 40000,
category: "交際費",
date: new Date(2026, 7, 20),
});
expect(route).toBe("manager_via_deputy");
});
test("少額かつ月次予算内なら自動承認になる", () => {
const route = decideApprovalRoute({
requesterId: "u001",
amount: 8000,
category: "交通費",
date: new Date(2026, 7, 20),
});
expect(route).toBe("auto");
});
test("少額でも当月の累計が予算上限を超えるなら自動承認にならない", () => {
mockChain("SpreadsheetApp.getActive.getSheetByName.getDataRange.getValues", [
["u001", "2026-8", 45000],
]);
const route = decideApprovalRoute({
requesterId: "u001",
amount: 8000,
category: "交通費",
date: new Date(2026, 7, 20),
});
expect(route).toBe("manager");
});
test("履歴シートが存在しない場合は当月累計0として扱う", () => {
mockChain("SpreadsheetApp.getActive.getSheetByName", null);
const route = decideApprovalRoute({
requesterId: "u001",
amount: 8000,
category: "交通費",
date: new Date(2026, 7, 20),
});
expect(route).toBe("auto");
});
});
最後のテストがわかりやすい例だと思っていて、「対象シートがまだ作られていない/名前が変わっている」という状態は、本番で実際にシートを一時的にリネームでもしない限り手動では再現しづらいケースです。
それを mockChain("...getSheetByName", null) の一行で確実に再現し、意図した通り 0 として扱われることを保証できます。
自動テストが効いてくるタイミング
このテストスイート自体は、書いた瞬間には「当たり前の動作確認」にしか見えないかもしれません。
価値が出るのはここからです。
半年後、経理から「交際費の上長承認ラインを3万円から2万円に下げてほしい」という依頼が来たとします。
ENTERTAINMENT_MANAGER_REVIEW_LIMIT を書き換えるだけの1行修正ですが、「この定数、他の分岐条件と干渉していないか」「月次予算判定のロジックに影響しないか」は、コードを目で追うだけでは自信を持ちきれません。
そんなとき、このテストスイートを再実行するだけで、5つの分岐すべてが想定通りに振る舞っているかを数秒で確認できます。
GitHub Actionsで push のたびに vitest run を走らせておけば、レビュー時点で「この修正は交通費の自動承認ラインを壊していません」ということが機械的に示せます。
まとめ
GASのコードをローカルでテストしようとすると、「GAS専用グローバルが存在しない」「 export するとGASでは動かない」という2つの壁があります。
@ciderjs/vitest-plugin-gas-mockとrolldown-plugin-remove-exportを組み合わせることで、ソースコードは普通にVitestでテストしつつ、 clasp push する成果物はそのままGASで動く形に変換する、というローカル開発の土台が作れます。
一度この安全網を張っておけば、後からロジックを触る人(未来の自分を含む)が、意図せず既存の挙動を壊してしまうリスクを大きく減らせます。
これこそが、Vitestを含む自動テストが本来提供している価値だと思います。