続Grunt + phantomjs + jasmineで自動テスト環境

目標はgrunt + phantomjs + jasmineの自動テスト環境

先日の大なごやJS Vol.3で、@_tk84さんが発表なさっていた、PhantomJSで自動テストにインスパイアされて、Gruntでそのあたりをコントロールできるようにしました。

今回のポイントは下記。

  • .coffeeを保存したら、.jsに自動でコンパイル
  • .jsの更新を検知して、SpecRunner.htmlを自動生成
  • このとき更新された.jsと、対になるテストコードを含んだSpecRunner.htmlが生成される
  • phantomjsで、SpecRunner.htmlを実行した結果を標準出力
  • 出力をgrowlnotifyに渡してデスクトップ通知

@_tk84さんの元ネタのほうでは、EmacsとRubyな環境でしたが、自分はエディタには依存せず、nodeの実行環境だけで何とかできるように構成しました。

今回の構成全容は、上記Githubのリポジトリで公開しています。

以下で紹介している実行内容は、すべてGruntのwatchを基点としたタスク実行です。watchタスクの中に、CoffeeScriptのコンパイルや、phantomjsによるテスト実行の処理を集めています。

% grunt watch

grunt-contribのcoffeeのかわりタスクを自作

常に全力でコンパイルを実行したがる&結合しちゃうので、ちょっとイマイチな感じでした。そんなに難しく無さそうなので、自分用でsutabaタスクを作ることに。src, destのルールはガン無視です。

// config抜粋
grunt.initConfig({
  sutaba: {
    fromTo: {
      'src/coffee'      : 'dist/js',
      'test/spec_coffee': 'test/spec'
    },
    watch: ['src/coffee/**/*.coffee', 'test/spec_coffee/**/*.coffee']
  },
  watch: {
    sutaba: {
      files: '<config:sutaba.watch>',
      tasks: 'sutaba'
    }
  }
});

// taskのほう
// coffee compile single file
grunt.registerTask('sutaba', function() {
  var done = this.async,
      changedSrc= grunt.file.watchFiles.changed[0] || grunt.file.watchFiles.added[0],
      fromTo = grunt.config('sutaba').fromTo;

  Object.keys(fromTo).forEach(function(path) {
    if (changedSrc.indexOf(path) === 0) {
      exec(['coffee', '-c', '-o', fromTo[path], changedSrc], function(err, out, code) {
        done();
      });
    }
  });
});

単一の.coffeeの保存時を想定したwatch専用で、grunt.file.watchFilesを参照して更新のあった1ファイルのみ、結合せずに同名で指定ディレクトリに.jsを書き出します。結合はconcatタスクで、minと合わせて別途やりたい。

Grunt 0.4.0から、watchで更新のあったファイルを取得できる

現在のstableなバージョンは0.3.11ですが、前述のgrunt.file.watchFilesは、0.4.0からの実装です。

I've added two things: a grunt.file.watchFiles object, and a grunt.file.match method. Those links point to the wip branch docs, so click them and read the docs. Issue #46: Watch task - potentially useful feature request · cowboy/grunt

つらつらとwatchに関するissueのスレッドを追っていたところ、watchFilesオブジェクトを追加したとの旨を発見、早速リンク先を追うと、そこはwipブランチ。

An object with two properties, grunt.file.watchFiles.changed and grunt.file.watchFiles.deleted that each contain an array of files changed or deleted since the last time the watch task was run. If the watch task hasn't run, both properties will be null. grunt/docs/api_file.md at wip · cowboy/grunt

おー、たしかに追加されている。恐らくこれこそが求めていたもの!tasks/watch.js と lib/grunt/file.js も確認したところ大丈夫そう。ということで、wipブランチを落としてきてnpm installすることに。

wipブランチをインストール

ここから、wipブランチのzipをダウンロードしてきて展開。

% npm install <展開先ディレクトリ> -g

これでバッチリ。しかし実行すると...

>> The Gruntfile name has changed to "Gruntfile.js", but a "grunt.js" file was
>> found. If this is your project's Gruntfile, please rename it. (Grunt 0.4.0+)
<FATAL> Unable to find Gruntfile. Do you need any --help? </FATAL>

これまでgrunt.jsだったのが、Gruntfile.jsに変更されていました。Gruntfile.jsにリネームして、滞りなく実行できるように。

SpecRunner.htmlの自動生成からphantomjsの実行

こんな感じでオリジナルのautospecタスクを、watch実行します。.jsの更新を監視しているので、CoffeeScriptを利用していないプロジェクトでも、普通にJavaScriptファイルを更新すれば、同じようにautospecがタスクが起動するようにしています。実際の処理は、ヘルパとしてphantomspecヘルパを登録しています。

// autospec
grunt.registerTask('autospec', function() {
  grunt.helper('phantomspec', grunt.file.watchFiles.changed, this.async());
});
// require
var ejsTmpl = require('ejs').compile(grunt.file.read('test/SpecRunner.ejs')),
    growl   = require('growl'),
    exec    = require('exec');

/**
 * Auto create SpecRunner.html and execute phantomjs.
 *
 * {Array}    changedFiles
 * {Function} asyncDone
 */
grunt.registerHelper('phantomspec', function(changedFiles, asyncDone) {
  var params = {
        path : []
      };

  if (!changedFiles.length) {
    return;
  }

  changedFiles.forEach(function(filename) {
    // spec modified
    if (filename.indexOf('test/spec') === 0) {
      params.path.push(filename);
      params.path.push(filename.replace('test/spec', 'dist/js'));
    }
    // dest modified
    else if (filename.indexOf('dist/js') === 0) {
      params.path.push(filename.replace('dist/js', 'test/spec'));
      params.path.push(filename);
    }
  });
  params.path = grunt.util._.uniq(params.path);
  grunt.file.write('test/SpecRunner.html', ejsTmpl(params));

  exec(['phantomjs', '--load-images=no', 'test/lib/run.jasmine.phantom.js', 'test/SpecRunner.html'], function(err, out, code) {
    console.log(out+'------------------------------------------------------');
    var mess = out.replace(/\[1m/g, '')
                  .replace(/\[0m/g, '')
                  .replace(/\[32m/g, '')
                  .replace(/\[31m/g, ''),
        options = {
          // ...some options...
        }
    ;
    growl(mess, options);
    asyncDone();
  });
});

ここでも、先ほどのgrunt.file.watchFileを利用して、更新されたファイルのみをテスト対象にしたSpecRunner.htmlを生成するようにします。テスト用HTMLを生成するテンプレートエンジンにはejsを利用しています。

更新されたファイル名を基準に、たとえばdist/js/hoge.jsがtest/spec/hoge.jsをペアとすることで、いずれかが更新されれば、両方のファイルをHTMLに差し込んで、テスト実行を行います。

phantomjs

テスト実行には表題通り、phantomjsを利用します。テスト実行結果の出力には、test/lib/run.jasmine.phantom.jsというのを利用しています。これは、maccman/spineに含まれていたのを拝借しています。

ここで1点ハマりポイントが。ejsテンプレートに、html的な文字コードの指定を忘れており、phantomjs内でevaluate → onConsoleMessage → console.log で出力されていた文字が化けていたという珍事に遭遇。

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

meta要素で指定すれば解決します。

growlnotify

何はともあれGrowlをインストールします。AppStoreに並んでる最新版(1.4)は有料なんですね。

Growl 1.4(¥170)App
カテゴリ: 仕事効率化, ユーティリティ
販売元: The Growl Project - The Growl Project, LLC(サイズ: 3.6 MB)

そいで、cliでgrowlをつつけるように、Growl DownloadsからGrowlNotify 1.3をダウンロードしてインストールします。


Growlでテスト結果を表示

npmでもgrowlというパッケージが見つかるので、それを利用してテスト結果のテキストを、Growlに送って表示させています。

ここまでやると、ファイル保存→.coffeeのコンパイル→phantomjsでテスト実行→Growlで表示の一連の流れが自動化されます。

コンソールにも表示させているので、無理にGrowlに表示しなくてもよかった感じでしたが、まあ、よいでしょう。


おわり

という感じで、@_tk84さんの環境をオマージュさせていただくことができました。phantomjsとnodeの実行環境があればよいので、エディタなどの環境を選ばず、うまく使い回せそうです。

当初はSublime Text 2に依存させようかと考えましたが、まだPhpStormも利用しているのでエディタには依存させない方向にしました。環境を選ばない、ローカル限定の可搬性という面も、いっちょ大事かな、というところで。