morishitaです。
時々、業務で使うツールをGASで作ります。
これまでのものはキャンペーン対応のものなど使い捨てとまでは言わないまでも、短い運用期間を想定したものでした1。
サービスレベル的にはベータレベルですが、少し運用期間が長くなりそう、かつプロトタイプ性が強くて変更が継続しそうなツールを作ることになったので、ちゃんとテストしようと思ってやってみました。
試したもの
次の要素を含むGASのプロジェクトでJestのユニットテストを導入しました。
- @google/clasp 2.1.0
- jest 24.8.0
- Typescript
ついでにこれも。
- eslint 6.0.1 + @typescript-eslint/eslint-plugin 1.11.0
紹介するサンプルコードはこちらです。
セットアップ
何はともあれ、必要なNodeモジュールをインストールして、GASのプロジェクトを作成します。
$ mkdir gas-ts-jest-eslint-sample $ cd gas-ts-jest-eslint-sample $ yarn add -D @google/clasp \ @types/google-apps-script \ @types/jest \ jest" \ ts-jest $ clasp create --type sheets --rootDir src --title GasTsJestEslint $ clasp pull $ mv src Code.js Code.ts
これでプロジェクトの雛形は出来上がりです。
Jestの設定
jest --init
コマンドを実行すると空の jest.config.jsを作ってくれます。
最低限の設定はこんな感じです。
テストコードはJestの作法に従って __tests__/ 以下に置くことにします。
module.exports = { globals: { 'ts-jest': { diagnostics: false, }, SpreadsheetApp: {}, }, moduleDirectories: [ 'node_modules', ], moduleFileExtensions: [ 'js', 'json', 'ts', 'tsx', ], preset: 'ts-jest', testEnvironment: 'node', transform: { '^.+\\.tsx?$': 'ts-jest', }, };
clasp push
時に *.tsファイルはトランスパイルされます。
でもclasp
内部で使っているts2gasがよしなにやってくれるのでTypescriptの設定tsconfig.jsonは特に必要ありません2。
GASの組み込みクラスを使わないモジュールのテスト
GASアプリケーションの一部であってもGAS固有のクラス等を使わない単なるロジックを実装したモジュールのテストは一般的なTypescriptのテストになります。
例として次のようなクラス(src/CalendarUtils.ts)を作ってみました。
class CalendarUtils { public static readonly DAY = { SUNDAY: 0, MONDAY: 1, TUESDAY: 2, WEDNESDAY: 3, THURSDAY: 4, FRYDAY: 5, SATURDAY: 6, }; /** * ○年○月の○番目の○曜日のDateオブジェクトを取得する * * 時間は 00:00 に設定している。 * @param year 年 * @param month 月(JS的に-1しなくていい) * @param nth 何番目か * @param day 曜日 */ static getNthDayDate(year: number, month: number, nth: number, day: number): Date { const firstDate = new Date(year, month - 1, 1); const firstDateDay = firstDate.getDay(); let firstDay = day - firstDateDay + 1; if (firstDay <= 0) firstDay += 7; firstDay += 7 * (nth - 1); const result = new Date(year, month - 1, firstDay); return result; } } export default CalendarUtils;
上記クラスのgetNthDayDate
メソッドのテストコード(__tests__/CalendarUtils.spec.ts)は次のとおりです。
import Utils from '../src/CalendarUtils'; describe('CalendarUtils', () => { it('2019年7月の第3土曜日', () => { const date = Utils.getNthDayDate(2019, 7, 3, Utils.DAY.SATURDAY); expect(date).toStrictEqual(new Date(2019, 7 - 1, 20)); }); it('2019年11月の第3土曜日', () => { const date = Utils.getNthDayDate(2019, 11, 4, Utils.DAY.SATURDAY); expect(date).toStrictEqual(new Date(2019, 11 - 1, 23)); }); });
見ての通りなんの変哲もないTypescriptのテストですね3。
GASの組み込みオブジェクトを使うモジュールのテスト
では、GASらしくG Suite servicesにアクセスするコードをテストしてみましょう。
例とするテスト対象のクラス(src/SpreadsheetUtils.ts)は次のとおりです。
class SpreadsheetUtils { private ss: GoogleAppsScript.Spreadsheet.Spreadsheet; /** * コンストラクタ * @param sheetId スプレッドシートID */ public constructor(sheetId: string) { this.ss = SpreadsheetApp.openById(sheetId); } /** * シートのデータをJSONとして取得する * @param name シート名 */ public getSheetAsJson(name: string): {[key: string]: any}[] { const sheet = this.ss.getSheetByName(name); const values = sheet.getDataRange().getValues(); const headers = values.shift(); const json = values.map((value) => { const row: {[key: string]: any} = {}; headers.forEach((header, i) => { row[String(header)] = value[i]; }); return row; }); return json; } } export default SpreadsheetUtils;
このクラスのインスタンスを生成するとコンストラクタで渡した sheetId
のスプレッドシートを開きます。
そしてgetSheetAsJson
メソッドを実行すると指定した名前のシートの内容をJSONに変換して返します。
例えば、次のようなシートがあったとします。
getSheetAsJson
メソッドはこのシートを1行目をヘッダとして、次のようなJSONを返します。
[ { "head1": "value A2", "head2": "value B2" }, { "head1": "value A3", "head2": "value B3" } ]
このメソッドのテストコード(__tests__/SpreadSheetUtils.spec.ts)は次の様になります。
import SpreadSheetUtils from '../src/SpreadSheetUtils'; // GASの独自オブジェクトはモックする SpreadsheetApp.openById = jest.fn(() => ({ getSheetByName: jest.fn(() => ( { getDataRange: jest.fn(() => ( { getValues: jest.fn(() => [ ['head1', 'head2'], ['value A2', 'value B2'], ['value A3', 'value B3'], ]), } )), } )), })); describe('SpreadSheetUtils', () => { it('JSONで取得できる', () => { const ssu = new SpreadSheetUtils('ssid'); const json = ssu.getSheetAsJson('sheet1'); expect(json[0].head1).toBe('value A2'); expect(json[0].head2).toBe('value B2'); expect(json[1].head1).toBe('value A3'); expect(json[1].head2).toBe('value B3'); }); });
まあ、GASの独自オブジェクトはモックするしか無いのでそうしています。
Jestに備わっているjest.fn()
を使って必要な関数・メソッドをモック関数で置き換えます。
ネストしていて見にくいですがこの例では最終的にSpreadsheetApp.openById().getSheetByName().getDataRange().getValues()
をモックしています。getValues()
はシートの内容を2次元配列で返すので、それをエミュレートしています。
SpreadSheetUtils
のテストではGAS固有のオブジェクトを直接モックしましたが、SpreadSheetUtils
クラスを利用するコードのテストではSpreadSheetUtils
自体をモックしたほうがいいと思います。
おまけ GAS で ESLint
ついでにESLintでコードをチェックできるようにします4。 必要なモジュールを次の通り追加します。
$ yarn add -D @typescript-eslint/eslint-plugin \ @typescript-eslint/parser \ eslint \ eslint-config-airbnb-base \ eslint-plugin-googleappsscript \ eslint-plugin-import \ eslint-plugin-jest
airbnb-base
を継承して次の様に設定しました。
module.exports = { "extends": [ "airbnb-base", ], "plugins": [ "@typescript-eslint", "googleappsscript", "jest", ], "parser": "@typescript-eslint/parser", "parserOptions": { "sourceType": "module" }, "env": { "node": true, "googleappsscript/googleappsscript": true, "jest/globals": true, }, "settings": { "node": { "tryExtensions": [".ts", ".js", ".json"], }, "import/resolver": { "node": { "paths": ["src", "__tests__"], "extensions": [".js", ".jsx", ".ts", ".tsx"], }, }, }, "rules": { } };
プラグインのgoogleappsscript
とjest
を使うと、それぞれで定義されるグローバルなモジュールや関数が未定義だと注意されなくなります。
また、次の内容で .eslintignoreも作っておきます。
src/appsscript.json
これで、GASが生成するファイルであるsrc/appsscript.jsonがチェックされなくなります。
まとめ
これで、GASもテストドリブンで開発できそうです。
最後に
アクトインディではエンジニアを募集しています。
-
いこーよの既存機能に依存しない独立性の高いもので、本体に組み入れてしまうと、リリース時のテストや利用終了時に除去する作業が大きくなってしまうので、GASを採用しました。↩
-
Jestの
moduleNameMapper
を使おうとすると、tsconfig.jsonを作ってcompilerOptions.paths
の設定が必要です。↩ -
型アノテーションも無いのでTypescriptでもなく、Javascriptのコードですね。↩
-
プロジェクトのルールに合ってるならTSLintでもいいんですが、Typescriptのチームも今後はESLintを使っていく方針の様なので。↩