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

  • 3
    Like
  • 0
    Comment

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

初期設定

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

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

テストの準備

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

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.jsonscripts フィールドに色々と書かなくて済むので捗ります。

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

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

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

テストコード

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

Terminal
$ mkdir test
$ touch test/Code-test.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 の実行がタイムアウトしてしまうので時間を延長しておきます。

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

テスト実行

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

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

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

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');
  });
});

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 も追加していますがそちらは好みでどうぞ。

Terminal
$ yarn add gulp-exec gulp-load-plugins
$ touch gulpfile.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"]);
});

それぞれを実行

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 サイトとして有名?な阿部寛のサイトを追加してテストしてみます。

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'), '阿部 寛のホームページ');
  });
});
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 関数があれば実行、なければ何もしないという処理にすると良いです。

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

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

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

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

Terminal
$ yarn add iconv-lite

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

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;
}
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'), '阿部 寛のホームページ');
  });
});

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

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 すっごーい!

そしてありがとう。

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