GASでテスト書くときのちょうどいい塩梅を探る

この記事は個人ブログの転載です

kotamat.com

GASは気軽にコードが書けるというのもあり、テストを書かないケースが多いのかなとは思っているのですが、とはいえある程度の規模になったらテストも書きたくなってくると思うので、どのへんをライン引きとしてテストを書くか、またそのテストの環境はどう設計するかを考えてみたいと思います。

Level 0: Webコンソールでいじる場合

Webコンソール上で直接触るケースはおそらく非エンジニアも触る環境ないしは、すぐに捨てるコードであることが多いかなと思います。 そういったケースの場合、テスト環境どころか開発環境に対してのカスタマイズする余地が殆どないか、その環境を作る過程で実装が終わってしまうレベルの案件となってしまい、費用対効果が出せない状態になるかと思います。

こういったときは潔くテストは書かず、ただつらつらとコードを書き、サクッとデプロイ・実行するというのが良いかなと思います。

Level 1: 保守が見込める場合

今後ある程度の期間使われ、かつ保守メンテが必要になるであろうスクリプトの場合、コード自体はバージョン管理ツールに載せた上で機能開発をしていく必要があるかと思います。

その場合は clasp を用い、ローカルでの開発をしていくことになるのですが、後述のLevel2に満たないレベルだとしても TypeScript 化して最低限の型担保はしておかないと逆に開発生産性が下がってしまうため、TS化をこの範囲での保守性担保のラインとします。

導入方法

導入方法は簡単で、

yarn add -D clasp @types/google-apps-script

を実行した上で、tsconfig.jsonに下記を記載します。

{
  "compilerOptions": {
    "lib": ["esnext"],
    "experimentalDecorators": true
  }
}

その後、下記コマンドを実行し、スクリプトを作成します

npx clasp create --type standalone --rootDir src

あとはsrcディレクトリにて .ts ファイルを作成し、コードを書いていくだけです。

リリースはclasp pushコマンドで反映でき、clasp openで当該GASをWebコンソールで確認することができます。

メリット

今回依存に追加している@types/google-apps-script はGASでよく使うAPIのインターフェースが予め実装されているため、

  • あれ、Spreadsheetから取得したsheetってどういうAPI持ってるんだっけ?
  • getRangeの引数ってrowが先だったっけ?columnが先だったっけ?

という細かな仕様確認を、型情報をみるだけでわかるようになるため、不必要なtypoや、間違ったコードを書きづらくなります。

当然この状態ではテストコードレベルの品質は担保できないため、API上は問題ないけど意図しない挙動が発生するリスクはあります。 そのため、そのあたりの担保もしたい場合はLevel2を検討する必要が出てきます

Level 2: コードの実行結果が実行してみないとわからない場合

スプレッドシートのデータを高度なロジックで置換する場合や、複数のリソースに対して参照し計算結果を算出する場合、時間発火のロジックを書く場合は、コードの実行結果が実行してみないとわからない状態になるかと思います。

ここまで複雑になってくると、テストコードなしでの開発そのものが生産性悪くなるため、外部APIとの通信をしているところと処理ロジック部分を分離した上で、処理ロジック部分のユニットテストを書く必要が出てきます。

考慮点

GASの場合下記を考慮する必要が出てきます。

  1. GAS本体にテスト実行基盤があるわけではないため、別途テストツールを導入する必要がある
  2. テストコードはGAS上では不要なファイルになるため、rootDir外に必要がある
  3. ファイルを切り出してテスタビリティを向上する場合、ファイルの命名規則が大事になる。

1.に関しては今回はjestを使ってみます。こちらもTSベースでコードを書くため、下記の依存をインストールします。

yarn add -D jest "@types/jest" ts-jest 

2.に関しては、Level1でも紹介したようにデプロイ対象のファイルを src/ディレクトリに入れ、テストコードを同階層の__tests__ディレクトリに設置することで解消します。

3.が躓いたポイントなのですが、GASそのものはexport/importの機能を有していないため、GASでTSを書くとexport/importの記述が消された上で反映されます。 GASはファイル名が若い順にコードを読み込むため、エントリーポイントとなるファイルよりもファイル名が後ろのファイルは読み込まれず、実行時エラーが発生します。 現在はエントリーポイントをindex.ts、依存系ファイルを_utils.tsのように_をつけて先頭で読み込まれるようにしています。ここイケてない感じがすごいので、いい方法あれば教えてほしいです。

実際の書き方

テストを書くとはいえ、このレベル感であればソースコードとしてはそれほど肥大化しないことが想定されます。 雑にUtilsというクラスを用意し、そこに諸々のロジックを詰め込んでいくことを考えてみます。

UtilsはGASがない環境でユニットテストを実行する必要があるため、GASのAPIには依存しないインターフェースにする必要があることだけ考慮し設計していきます。

export class Utils {
  constructor(private now: Date = new Date) {}

  public isDateInMinute(date: Date, minute: number = 5): boolean {
      const minutesAfter: Date = new Date(this.now.getTime())
      minutesAfter.setTime(minutesAfter.getTime() + (1000 * 60 * minute))
      return this.now.getTime() < date.getTime() && date.getTime() <= minutesAfter.getTime()
  }
}

コンストラクタに何を入れるかと言うのは議論の余地はありそうですが、GASで頻出する表現は時間発火の概念なので、第一引数に現在を示す変数を入れていきます。

ここで雑にisDateInMinuteという関数を考えてみます。これは引数に指定した日時が現在時刻と比較して所定の分数の間にあるかどうかを判定する関数です。GASは毎分起動するということを設定できるため、スプレッドシートに記載した時間に発火するとかができるようになります。

これをベースにテストを書いてみます。

import { Utils } from "../src/_utils";

const data_isDateInMinute = [
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:01:00",
        inMinutes: 5,
        result: true
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:06:00",
        inMinutes: 5,
        result: false
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:05:00",
        inMinutes: 5,
        result: true
    },
    {
        now: "2021-06-07 10:00:00",
        target: "2021-06-07 10:00:00",
        inMinutes: 5,
        result: false
    },
]
describe.each(data_isDateInMinute)('Utils.isDateInMinute', (data) => {
    it(`${data.now} to ${data.target} in ${data.inMinutes} minutes?`, () => {
        const util = new Utils(new Date(data.now))
        const targetDate = new Date(data.target)
        expect(
            util.isDateInMinute(targetDate, data.inMinutes)
        ).toBe(data.result)
    })
})

このテストコードでは境界値を確認するテストを入れてみました。jestのdescribe.eachを使うことで、複数のシナリオを同時に実行できます。 大事なのはit()のところで new Utils(new Date(data.now))としているところかなと思います。時間に依存している処理を、コンストラクタでDIすることにより、実行時間に依存しないテストにすることができています。

僕はめんどくさがりなので(?)テーブルドリブンなテストケースの作成にjsonを書くとフラストレーションがたまります。 その場合は下記のようにcsvにテストケースを書き、csvを読み込んでシナリオを構築するのでもいいでしょう。いくらここで依存ファイルをimportしたところでGASのアウトプットには一切入ってこないので。

import { Utils } from "../src/_utils";
import csv from "csv-parse/lib/sync";
import fs from "fs";
import path from "path";

// CSVから同期的に読み込む
const data_isDateInMinute = csv(fs.readFileSync( path.join(__dirname, './data/isDateInMinute.csv')))
describe.each(data_isDateInMinute)('Utils.isDateInMinute', (data) => {
    it(`${data.now} to ${data.target} in ${data.inMinutes} minutes?`, () => {
        const util = new Utils(new Date(data.now))
        const targetDate = new Date(data.target)
        expect(
            util.isDateInMinute(targetDate, data.inMinutes)
        ).toBe(data.result)
    })
})

メリット

当然ここまで書けば、テストしたい粒度のものをUtilにぶちこんであげるだけで簡単にテストをかけるようになってきます。殆どのケースであればここまでやっておけばいいでしょう。

Level 3: Webアプリケーションを作る

もはやここまで来ると、GASが動く環境をサーバーとした、Webアプリケーションを作るレベルになるかと思います。 この領域になるとWebアプリケーションとしての設計やテストを求められるようになるため、webpackでまとめたり、テストの分割の仕方も一筋縄ではいかなくなるかと思います。 infrastructure層をGASの外部APIとしたような設計を行い、ユニットテストだけではなくフィーチャーテストも書くようになるかもしれません。

正直ここの領域までGASでやろうと思ったことはないので未知数ですが、やる機会があったらチャレンジしてみようと思います。

まとめ

GASの保守性担保の方針を考えてみました。もしもっとこういうのやってみるといいかもとかあればTwitterまでいただけるとうれしいです