Titaniumでユニットテスト Jasmine再び編

いちいちiPhoneシミュレータを起動して結果を確認するのはだるいのではないか、単体テストが有効なクラスならばTitanium外で作ってもいいんじゃない?と思いこないだはNode.jsを利用したテストに走ったわけだけど、TitaniumのAPIを利用したクラスのテストをしたくなりTitanium内に舞い戻った。

スタブとかモックを使えばいいじゃないって?
いやー、それもなかなかしんどいしね。それに特に問題なかったら本物使った方がいいと思いますよ。

2011/02/17 追記

試行錯誤の成果をまとめたJasmine Titaniumを公開しました。

  • 出力を見やすく調整
  • 個別のスペックのみ実行できるように

といった改良が図られているのでこの記事の内容よりオススメです。
追記終わり。

準備

次のものはすべてResource以下での出来事。

Jasmine

Jasmineのコアを準備。
以前作ったTitanium用のReporterも利用。

  • lib/jasmine/jasmine.js
  • lib/jasmine/jasmine-titanium.js
Runner
  • titanium-specs.js

jasmin-nodeのspecs.jsにあたるものを作りました。

var spec, specDir, _i, _len, _ref;
Ti.include("lib/jasmine/jasmine.js");
Ti.include("lib/jasmine/jasmine-titanium.js");
specDir = Ti.Filesystem.getFile('spec/');
_ref = specDir.getDirectoryListing();
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
  spec = _ref[_i];
  if (spec.match(/_spec.js$/)) {
    Ti.include("lib/" + spec.replace("_spec.js", ".js"));
    Ti.include("spec/" + spec);
  }
}
jasmine.getEnv().addReporter(new jasmine.TitaniumReporter());
jasmine.getEnv().execute();
alert('spec runner finished');

specディレクトリからスペックファイル(ファイル名が_spec.jsで終わるもの)を探して、スペックファイルのファイル名と対応するクラスファイルをlib内から探し出してinclude。
libとspecは決めうち。

コード例

app.js

titanium-specs.jsをincludeするのみ。

Ti.include("titanium-specs.js")

実際のアプリのコードとの混在については少し後で。
とりあえずスペックだけ走らせる。

lib/map_helper.js

MapView関連のメソッドをまとめておく用に定義したクラス。
いかにもモンスタークラスに育ちそうな名前だが、育ちすぎを感じたら随時リファクタリングしていくとしよう。
テストがあるから「後でリファクタリング」が大人の嘘にならない。
「後で」というか「必要に応じて随時」だけど。

var MapHelper;
MapHelper = (function() {
  function MapHelper() {}
  MapHelper.prototype.annotations = [];
  MapHelper.prototype.defaultRegion = {
    latitude: 35.5211181640625,
    longitude: 138.0273132324219,
    latitudeDelta: 7.0,
    longitudeDelta: 7.0
  };
  MapHelper.prototype.loadLatestRegion = function() {
    if (!Ti.App.Properties.hasProperty("latestLatitude")) {
      return this.defaultRegion;
    }
    return {
      latitude: Ti.App.Properties.getDouble("latestLatitude"),
      longitude: Ti.App.Properties.getDouble("latestLongitude"),
      latitudeDelta: Ti.App.Properties.getDouble("latestLatitudeDelta"),
      longitudeDelta: Ti.App.Properties.getDouble("latestLongitudeDelta")
    };
  };
  MapHelper.prototype.saveLatestRegion = function(region) {
    Ti.App.Properties.setDouble("latestLatitude", region.latitude);
    Ti.App.Properties.setDouble("latestLongitude", region.longitude);
    Ti.App.Properties.setDouble("latestLatitudeDelta", region.latitudeDelta);
    Ti.App.Properties.setDouble("latestLongitudeDelta", region.longitudeDelta);
  };
  return MapHelper;
})();
spec/map_helper_spec.js

スペック。
実際はテストファーストしてますよ。

describe("MapHelper", function() {
  beforeEach(function() {
    var prop, _i, _len, _ref, _results;
    this.mapHelper = new MapHelper();
    _ref = ['latestLatitude', 'latestLongitude', 'latestLatitudeDelta', 'latestLongitudeDelta'];
    _results = [];
    for (_i = 0, _len = _ref.length; _i < _len; _i++) {
      prop = _ref[_i];
      _results.push(Ti.App.Properties.removeProperty(prop));
    }
    return _results;
  });
  describe("アプリケーションプロパティに最後に表示したRegionを保存する場合", function() {
    it("保存できること", function() {
      this.mapHelper.saveLatestRegion({
        latitude: 1.0,
        longitude: 2.0,
        latitudeDelta: 3.0,
        longitudeDelta: 4.0
      });
      expect(1.0).toEqual(Ti.App.Properties.getDouble('latestLatitude'));
      expect(2.0).toEqual(Ti.App.Properties.getDouble('latestLongitude'));
      expect(3.0).toEqual(Ti.App.Properties.getDouble('latestLatitudeDelta'));
      expect(4.0).toEqual(Ti.App.Properties.getDouble('latestLongitudeDelta'));
    });
    it("保存した値を読み出せること", function() {
      var region;
      this.mapHelper.saveLatestRegion({
        latitude: 1.0,
        longitude: 2.0,
        latitudeDelta: 15.0,
        longitudeDelta: 15.0
      });
      region = this.mapHelper.loadLatestRegion();
      expect(1.0).toEqual(region.latitude);
      expect(2.0).toEqual(region.longitude);
    });
    it("保存した値がない場合はデフォルトのRegionを返すこと", function() {
      var region;
      region = this.mapHelper.loadLatestRegion();
      expect(1.0).toNotEqual(region.latitude);
      expect(this.mapHelper.defaultRegion.latitude).toEqual(region.latitude);
    });
  });
});
実行

これでシミュレータを起動。
ログはこんなかんじ。

[INFO] Runner Started.
[DEBUG] アプリケーションプロパティに最後に表示したRegionを保存する場合 : プロパティに保存できること ... 
[DEBUG] application booted in 75.909972 ms
[DEBUG] Passed.
[DEBUG] アプリケーションプロパティに最後に表示したRegionを保存する場合 : 保存した値を読み出せること ... 
[DEBUG] Passed.
[DEBUG] アプリケーションプロパティに最後に表示したRegionを保存する場合 : 保存した値がない場合はデフォルトのRegionを返すこと ... 
[DEBUG] Passed.
[DEBUG] アプリケーションプロパティに最後に表示したRegionを保存する場合: 8 of 8 passed.
[DEBUG] MapHelper: 8 of 8 passed.
[INFO] 3 specs, 0 failures in 0.058s
[INFO] Runner Finished.

なかなか上出来ではないでしょうか。

アプリとテストの共存

アプリとテスト。まざるとキケン。
通常のapp.jsの内容にテストを走らせるコードを混ぜたくない。

そこでアプリ起動用とテストを走らせる用のシェルスクリプトを別々に作成して、コマンドラインから叩くことで起動するようにしてみた。

app.jsからは

Ti.include("titanium-specs.js")

は削除して、通常のTitaniumアプリケーションのapp.jsと同様の内容にしておく。

run.sh

こちらは普通にアプリを起動する。

#!/bin/sh
/Library/Application\ Support/Titanium/mobilesdk/osx/1.5.1/iphone/builder.py run ~/works/myproject/
specs.sh

テストを実行するスクリプト。

#!/bin/sh
cp app.js app.js.backup
cp titanium-specs.js app.js
/Library/Application\ Support/Titanium/mobilesdk/osx/1.5.1/iphone/builder.py run ~/works/myproject/
cp app.js.backup app.js
rm app.js.backup

app.jsをいったん退避させてtitanium-specs.jsをapp.jsにして起動することでテストを実行。
実行後もとのapp.jsに戻すという力技。
明らかにもっとスマートな方法がありそうだけどうまく動くからよしとしましょう。

残っている問題

しかし結局アプリの動作環境とテストの動作環境が分離してないので、テスト走らせたらプロパティとかDBとかの内容がガタガタになります。
あとGUIから実行する場合はアプリとテストが分けられない。

まだまだTitaniumを知らないことが多いのでもしかするともっとうまい方法があるのかもしれない。

Titaniumでユニットテスト Jasmine再び編” に対して1件のコメントがあります。

コメントは受け付けていません。

前の記事

jasmine-nodeで非同期処理のテスト