アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

TypescriptのGASをJestでテストする

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に変換して返します。

例えば、次のようなシートがあったとします。

f:id:HeRo:20190702081339p:plain
スプレッドシートのサンプル

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": {
  }
};

プラグインのgoogleappsscriptjestを使うと、それぞれで定義されるグローバルなモジュールや関数が未定義だと注意されなくなります。

また、次の内容で .eslintignoreも作っておきます。

src/appsscript.json

これで、GASが生成するファイルであるsrc/appsscript.jsonがチェックされなくなります。

まとめ

これで、GASもテストドリブンで開発できそうです。

最後に

アクトインディではエンジニアを募集しています。

actindi.net


  1. いこーよの既存機能に依存しない独立性の高いもので、本体に組み入れてしまうと、リリース時のテストや利用終了時に除去する作業が大きくなってしまうので、GASを採用しました。

  2. JestのmoduleNameMapperを使おうとすると、tsconfig.jsonを作ってcompilerOptions.pathsの設定が必要です。

  3. 型アノテーションも無いのでTypescriptでもなく、Javascriptのコードですね。

  4. プロジェクトのルールに合ってるならTSLintでもいいんですが、Typescriptのチームも今後はESLintを使っていく方針の様なので。