gulp と mocha を使って UrlFetchApp のテストをする方法


自分は Google Apps Script（以下GAS）大好き人間なのですが、以下２点の不満があります。

- ブラウザでエディタを開いて作業しなければいけない。
- ローカルでのテストが書けない。

テストについてはいくつかGAS対応のフレームワークがあるようですが、どれも既存のプロジェクトに組み込んで使用するようです。

- [how to unit test google apps scripts? - Stack Overflow](http://stackoverflow.com/questions/15682346/how-to-unit-test-google-apps-scripts)
- [zixia/gast: Google Apps Script TAP Testing-framework](https://github.com/zixia/gast)
- [Google Code Archive - Long-term storage for Google Code Project Hosting.](https://code.google.com/archive/p/gas-unit/)

Slack用勤怠管理Botの「みやもとさん」では ``QUnit`` を使っているようです。

- [masuidrive/miyamoto: Google Apps Scriptで書かれたSlack用勤怠管理Botの「みやもとさん」](https://github.com/masuidrive/miyamoto)
- [GoogleAppsScriptでユニットテスト - Qiita](http://qiita.com/yooo_gooo/items/07c9513a87f6633e40c1)
- [QUnit for Google Apps Script でユニットテストを行う手順 – oki2a24](http://oki2a24.com/2015/10/06/how-to-test-with-qunit-for-google-apps-script/)

でも既存プロジェクトに組み込む形だと結局ブラウザでエディタを開いて書くことになるんですよね。。

そんな不満を解決してくれるかと思って以下の記事を試してみましたが、、

- [Google Apps Scriptの開発をモダンに行う方法 - Speee DEVELOPER BLOG](http://tech.speee.jp/entry/2016/04/28/190236)

残念ながら自分が一番やりたいことができなかったです。

それは **「UrlFetchApp などを使ってるコードのテストはどうやるの？」** ということ。

GASには ``UrlFetchApp`` の他にも ``MailApp`` や ``SpreadsheetApp`` など多数のオリジナルな便利クラスが用意されています。
最悪、他はできなくても ``UrlFetchApp`` を使ったコードのテストだけでもできれば、APIで取得した内容をSlackに投げるとかの処理でテストが書けます。

何かいい方法はないかなーと探していると素晴らしいツールを見つけました。

## gas-local でモックを作って解決

``gas-local`` はGASのモック作成ライブラリです。

- [mzagorny/gas-local: Execute and test your google app scripts locally in node.js](https://github.com/mzagorny/gas-local)

Github の説明には「Execute and test」と書かれていますがテストの機能は含まれておらず、``mocha`` などと一緒に使う必要があります。

- [Mocha - the fun, simple, flexible JavaScript test framework](https://mochajs.org/)
- [MochaでJavaScriptのテストを書く - CLOVER](http://d.hatena.ne.jp/Kazuhira/20160306/1457271963)

``UrlFetchApp`` だけでなく、モックを用意すれば ``MailApp`` など他のクラスでも対応可能です。

Readme にあるのは ``MailApp`` のモックを作るやり方だけなので、実際に ``UrlFetchApp`` のモックを作ってテストを書いてみます。

## 初期設定

初期設定については主に前述の記事にある通りです。

- [Google Apps Scriptの開発をモダンに行う方法 - Speee DEVELOPER BLOG](http://tech.speee.jp/entry/2016/04/28/190236)

```bash:Terminal
$ npm install -g node-google-apps-script
$ gapps auth ./client_secret_<client_secret_key>.json
Please visit the following url in your browser (you will only have to do this once): https://accounts.google...
-> URLを踏んで認証
Successfully Authenticated with Google Drive!
$ mkdir gas-test
$ cd gas-test
$ gapps init <Project ID>
# Example
# gapps init jiofe3g4jHfr5dekD5deNIO9e...
$ tree .
.
├── gapps.config.json
└── src
    └── コード.js
```

## テストの準備

テスト対象のコードを用意します。
以下のコードを少し改変して使用します。

- [Google Apps Scriptでスクレイピングする方法 - Qiita](http://qiita.com/ina6ra/items/23248b7290d8140f53fc)

```js:src/コード.js
function myFunction(url) {
  if( url == null ) url = 'http://www.yahoo.co.jp';

  var response = UrlFetchApp.fetch(url);

  var myRegexp = /<title>([\s\S]*?)<\/title>/i;
  var match = myRegexp.exec(response);
  var title = match[1];

  title = title.replace(/(^\s+)|(\s+$)/g, '');
  Logger.log(title);
  return title;
}
```

次はテストに必要なライブラリを追加していきます。
``yarn`` を使うと ``package.json`` の ``scripts`` フィールドに色々と書かなくて済むので捗ります。

- [gulp や mocha を使う時は yarn が便利 - Qiita](http://qiita.com/ina6ra/items/b024c1b25a85d38db545)

```bash:Terminal
$ npm install -g yarn
$ yarn add gulp-mocha gas-local sync-request
```

``gas-local`` は前述の通り、GASのモック作成ライブラリです。

``sync-request`` は同期的にリクエスト処理を実行してくれるライブラリです。
Node.js では基本的に非同期処理でリクエストが実行されるるので、``UrlFetchApp`` と同じく同期的にリクエスト処理を行うにはこのライブラリが必要となってきます。

- [Node.js で取得した Web ページを変数に保存して利用する方法 - Qiita](http://qiita.com/ina6ra/items/6770e12515fcde695c84)

## テストコード

準備ができたらテストコードを用意します。

```bash:Terminal
$ mkdir test
$ touch test/Code-test.js
```

```js:test/Code-test.js
var assert = require('assert');
var gas = require('gas-local');
var request = require('sync-request');

// ローカルでのテストでは Logger クラスを無効化する
gas.globalMockDefault.Logger.enabled = false;

var defMock = gas.globalMockDefault;
var customMock = {
  // UrlFetchApp クラスのモックを作成
  UrlFetchApp: {
    // fetch 関数と同じ動きをする処理を書いていく
    fetch: function(url) {
      var req = request('GET', url);
      return req.body.toString();
    }
  }, __proto__: defMock
};

// ソースフォルダの指定はプロジェクトルートからの相対パス
var glib = gas.require('./src', customMock);

describe('コード.js', function() {
  // mocha がタイムアウトするまでの時間を延長（default:2000ms）
  this.timeout(5000);

  it('myFunction for yahoo', function() {
      assert.equal(glib.myFunction(), 'Yahoo! JAPAN');
  });
});
```

モックの作り方はコードを見るとなんとなくわかると思います。
``UrlFetchApp.fetch`` 関数を用意し、それと同じ動きをする処理を ``sync-resquest`` を利用して作成します。

また、``Logger.log`` での出力処理はローカルテスト時には必要ないので無効化しておき、
サイトによってはアクセスに時間がかかって ``mocha`` の実行がタイムアウトしてしまうので時間を延長しておきます。

- [javascript - Change default timeout for mocha - Stack Overflow](http://stackoverflow.com/questions/23492043/change-default-timeout-for-mocha)

あと注意点として、 **ソースフォルダの指定はプロジェクトルートからの相対パス** で行います。
``test`` フォルダ内に用意したテストコードに処理を書いているので、``../src`` と書きたくなるところですが、それだとエラーになります。

## テスト実行

準備ができたらテストを実行します。

```bash:Terminal
$ yarn mocha
yarn mocha v0.21.3
$ "/path/to/code/gas-test/node_modules/.bin/mocha"

  コード.js
    ✓ myFunction for yahoo (1290ms)

  1 passing (1s)

✨  Done in 1.97s.
```

おおー！うまくいきました。

> ※たまに以下のようなポップアップが表示されます。
>  「許可」を選択するか少し待つと自動的に消えます。

![スクリーンショット 2017-04-05 23.01.41.png](https://qiita-image-store.s3.amazonaws.com/0/12542/190f1ca5-ab81-55fa-141a-e137a7ceb342.png "スクリーンショット.png")


別のサイトも試してみましょう。

```js:test/Code-test.js
〜略〜

  it('myFunction for yahoo', function() {
      assert.equal(glib.myFunction(), 'Yahoo! JAPAN');
  });
  // 追加
  it('myFunction for livedoor', function() {
      assert.equal(glib.myFunction('http://www.livedoor.com/'), 'livedoor');
  });
});

```

```bash:Terminal
$ yarn mocha
yarn mocha v0.21.3
$ "/path/to/code/gas-test/node_modules/.bin/mocha"

  コード.js
    ✓ myFunction for yahoo (1232ms)
    ✓ myFunction for livedoor (244ms)

  2 passing (1s)

✨  Done in 2.25s.
```

素晴らしい…！

## テストが通ったらアップする

ここまでできればあとは ``gulp`` を用いれば簡単です。

``gapps`` コマンドを使いたいので、タスク内でコマンドの実行ができる ``gulp-exec`` を追加します。
``gulp-load-plugins`` も追加していますがそちらは好みでどうぞ。

- [robrich/gulp-exec: exec plugin for gulp](https://github.com/robrich/gulp-exec)
- [[gulp] 効率的にプラグインを読み込む - Qiita](http://qiita.com/superbrothers/items/a342f3580b8acb5b9b06)

```bash:Terminal
$ yarn add gulp-exec gulp-load-plugins
$ touch gulpfile.js
```

```js:gulpfile.js
var gulp = require("gulp");
var $ = require("gulp-load-plugins")();

gulp.task("mocha", function() {
  return gulp
  	.src(["test/*.js"])
	.pipe($.mocha({ reporter: "spec" }));
});

gulp.task("upload", ["mocha"], function() {
  return gulp.src(".")
	.pipe($.exec("gapps upload"));
});

gulp.task("watch", function() {
  return gulp
	.watch(["src/**", "test/**"], ["upload"]);
});
```

それぞれを実行

```bash:Terminal
$ yarn gulp mocha
# mocha でのテストだけを実行
$ yarn gulp upload
# mocha でのテストを実行し、エラーがなければアップロードを実行
$ yarn gulp watch
# ファイルを監視して変更があったら upload タスクを実行
```

わりと簡単にできてしまいました…！

ブラウザでアップされたファイルを開いて実行してみましょう。

```ログ
[17-04-XX 19:26:08:208 PDT] Yahoo! JAPAN
```

バッチリですね！
URL を livedoor に書き換えてもきちんと動きます。

## シフトJIS サイトに対応する

シフトJIS サイトとして有名？な阿部寛のサイトを追加してテストしてみます。

```js:test/Code-test.js
〜略〜

  it('myFunction for livedoor', function() {
      assert.equal(glib.myFunction('http://www.livedoor.com/'), 'livedoor');
  });
  // 追加
  it('myFunction for Shift_JIS', function() {
      assert.equal(glib.myFunction('http://abehiroshi.la.coocan.jp/top.htm'), '阿部 寛のホームページ');
  });
});
```

```bash:Terminal
$ yarn mocha
yarn mocha v0.21.3
$ "/path/to/code/gas-test/node_modules/.bin/mocha"

  コード.js
    ✓ myFunction for Yahoo! (1145ms)
    ✓ myFunction for livedoor (308ms)
    1) myFunction for Shift_JIS

  2 passing (2s)
  1 failing

  1) コード.js myFunction for Shift_JIS:

      AssertionError: '���� ���̃z�[���y�[�W' == '阿部 寛のホームページ'
      + expected - actual

      -���� ���̃z�[���y�[�W
      +阿部 寛のホームページ
      
      at Context.<anonymous> (test/Code-test.js:38:14)
error Command failed with exit code 1.
```

エラーになりました。
文字コードを指定してない場合は ``UTF-8`` となるので当然ですね。

さてここから修正していくのですが、GASならでの問題を解決していく必要があります。

まず、GASでは ``UrlFetchApp.fetch`` から返ってきたオブジェクトに対して ``getContentText`` 関数を用いて文字コードを指定します。
モックを作成する処理では取得した内容をテキストとして返しているので、当然ながら ``getContentText`` がなくてエラーになります。
かと言って ``getContentText`` での文字コード指定を書かないと、ローカルでのテストは通っても Web での実行はエラーになります。

この問題を手っ取り早く解決するには、``UrlFetchApp.fetch`` から返ってきたオブジェクトを調査して ``getContentText`` 関数があれば実行、なければ何もしないという処理にすると良いです。

- [JavaScriptで関数の存在有無を見分けるには - shohu33's diary](http://shohu.hatenablog.com/entry/20080122/1200977478)

次に、モックとして作成した ``UrlFetchApp.fetch`` にどうやって指定する文字コードを渡すかという問題があります。

前述のように ``UrlFetchApp.fetch`` には文字コードを指定するオプションはなく、返ってきたオブジェクトに対して文字コードを指定します。

Web の方でエラーが出ないように本来は指定する必要のない情報を渡すにはどうすればいいか試行錯誤した結果、``contentType`` の ``charset`` で指定するのが良さそうなことがわかりました。
※この点についてはもっと適切な方法がある気がするので詳しい方はコメントください。

渡されたオプションから ``charset`` を取得する際は Node.js の標準ライブラリである ``querystring`` を使い、指定した文字コードへの変換には ``iconv-lite`` を使うことにします。

```bash:Terminal
$ yarn add iconv-lite
```

- [JavaScriptで関数の存在有無を見分けるには - shohu33's diary](http://shohu.hatenablog.com/entry/20080122/1200977478)
- [PHP でレスポンスヘッダーの Content-Type に文字コードを指定する方法 | Webセキュリティの小部屋](https://www.websec-room.com/2013/03/02/359)
- [Query String | Node.js v7.8.0 Documentation](https://nodejs.org/api/querystring.html)
- [node.js - nodejs encoding using request - Stack Overflow](http://stackoverflow.com/questions/12040643/nodejs-encoding-using-request)
- [Node.jsでデータの文字コードを変換する - 情報アイランド](http://info-i.net/iconv-lite)
- [ashtuchkin/iconv-lite: Convert character encodings in pure javascript.](https://github.com/ashtuchkin/iconv-lite)

以上の点をふまえて修正したコードが以下になります。

```js:src/コード.js
// 引数に char を追加
function myFunction(url, char) {
  if( url == null ) url = 'http://www.yahoo.co.jp';
  if( char == null ) char = 'utf8';

  // GASの contentType のデフォルトは application/x-www-form-urlencoded
  var params = {
    'contentType': 'application/x-www-form-urlencoded; charset='+char
  };

  var response = UrlFetchApp.fetch(url, params);

  // getContentText 関数の存在チェック
  if( typeof(response.getContentText) === 'function' )
    response = response.getContentText(char);

  var myRegexp = /<title>([\s\S]*?)<\/title>/i;
  var match = myRegexp.exec(response);
  var title = match[1];

  title = title.replace(/(^\s+)|(\s+$)/g, "");
  Logger.log(title);
  return title;
}
```

```js:test/Code-test.js
var assert = require('assert');
var gas = require('gas-local');
var request = require('sync-request');
var querystring = require('querystring'); // 追加
var iconv = require('iconv-lite'); // 追加

gas.globalMockDefault.Logger.enabled = false;

var defMock = gas.globalMockDefault;
var customMock = {
  UrlFetchApp: {
    // 文字コード指定に対応するよう修正
    fetch: function(url, params) {
      var req = request('GET', url);
      var char = params.contentType.replace(/\s/g, '');
      char = querystring.parse(char, ';');
      req = iconv.decode(req.body, char.charset);
      return req.toString();
    }
  }, __proto__: defMock
};

〜略〜

  it('myFunction for Shift_JIS', function() {
      // 文字コードを引数で指定
      assert.equal(glib.myFunction('http://abehiroshi.la.coocan.jp/top.htm', 'sjis'), '阿部 寛のホームページ');
  });
});

```

テスト（とアップロード）を実行します。

```bash:Terminal
$ yarn gulp upload
yarn gulp v0.21.3
$ "/path/to/code/gas-test/node_modules/.bin/gulp" upload
[17:39:22] Using gulpfile /path/to/code/gas-test/gulpfile.js
[17:39:22] Starting 'mocha'...

  コード.js
    ✓ myFunction for yahoo (2435ms)
    ✓ myFunction for livedoor (319ms)
    ✓ myFunction for Shift_JIS (311ms)

  3 passing (3s)

[17:39:25] Finished 'mocha' after 3.73 s
[17:39:25] Starting 'upload'...
[17:39:33] Finished 'upload' after 7.48 s
✨  Done in 15.65s.
```

無事にテストが通ってアップされました！

```ログ
[17-04-XX 19:26:08:208 PDT] Yahoo! JAPAN
```

Web の方でもエラーなく実行されます。
URL を阿部寛のサイトに書き換えても、シフトJISを指定すればきちんと動作します。

## まとめ

既存のコードに多少手を加える必要はありますが、GAS の独自関数のテストが書けるようになりました。

**gas-local すっごーい！**

そしてありがとう。

- [mzagorny/gas-local: Execute and test your google app scripts locally in node.js](https://github.com/mzagorny/gas-local)

今回のコードは Github に置いておくので、動作を確認したい方などはご自由にどうぞ。

- [ina6ra/gas-test](https://github.com/ina6ra/gas-test)