2014年 01月 13日

AngularJS のテスト

とりあえず、2種類のテストがあり、どちらも十分なサポートがされている。

基本的にangular-seedというのを元に作ればいいんだけど、e2e (end to end) テストについては protractor というのを使うのが新しいようなので、今からはじめるならそちらを使ったほうが良い。

karma での unit テスト

node で完結する、ロジックの単体テスト。主に controller とか filter をテストする。controller で DOM を直でいじっていると実行できない。

サーバサイドとかとの通信とかは全てモックにしなければならない。Angular の DI の仕組みで、モックオブジェクトを外部から注入して単体テストを完結させる。

いろいろ面倒くさいけど、これを書くようにすることで controller / directive の使いわけとかを意識せざるを得なくなるので良い気がする。

protractor による end to end テスト

selenium を使った結合テスト。

protractor は Angular JS 用の e2e テストライブラリ。簡単に selenium-standalone をセットアップするところから、テスト用のユーティリティまでのセット。ドキュメント の通りにやれば OS X では全く苦もなく selenium 環境を作りテストを開始できる。

どこが「Angular JS用」なのかというと、ページロードとか、イベント発火とかで、いちいち自分で wait() を書く必要がなく、Angular 準拠の部分は自動で処理待ちをするので、かなり楽をできる。

karma か protractor か

  • karma のテストは早い
  • protractor (selenium) は遅い

ので、パターンを網羅したロジックは書きたいなら karma で完結するように書いたほうがいい。

AngularJS のテストでページ側のスクリプトを実行する

protractor (webdriver) を使った場合、外から executeAsyncScript を使うと文字列でページ側で実行できる。

けど、文字列で渡すとか、シンタックスチェックもかからないし、ありえないので、定義自体は普通に書きたい。ので以下のような関数を定義する。

var PageObject  = function () {
	this.exec = function (func) {
		var args = Array.prototype.slice.call(arguments, 0);
		args[0] = '('+ (func.toString()) + ').apply(null, arguments);';
		browser.executeAsyncScript.apply(browser, args);
	};

	this.createEntry = function (data) {
		this.exec(function (data, callback) {
			angular.injector(['myApp']).invoke(function (Entry) {
				var entry = new Entry();
				for (var key in data) if (data.hasOwnProperty(key)) entry[key] = data[key];
				entry.$save(callback);
			});
		}, data);

		return data;
	};
};

この例では、定義した exec 関数を使って、ページ側の ngResource で定義したクラスを使い、テスト用のデータを生成する createEntry メソッドを定義している。

AngularJS でまだわからないこと

  • 1つのページに複数のコントローラーを定義し、それぞれを連携させる方法がわからない
    • scope はどうなる?
  • スコープまわりがよくわかってない
    • $on、$apply の伝搬がよくわかってない
  • DI まわりがよくわかってない
    • 何を service にして何を provider にするのかとか
2014年 01月 08日

AngularJS の ngResource を既存APIの仕様にあわせる

AngularJS には ngResource という拡張があって、サーバに対する API 経由の CRUD 的操作を JavaScript のオブジェクトとしてラッピングできる。具体的には例えば

var Entry = $resource('/entry/:id');
var entry = Entry.get({ id : 0 }, function () {
    entry.title = "yuno";
    entry.$save(); // XHR (async)
});

とかできる。ちょっとかっこいいけど、既存APIで使おうとすると、些細なフォーマットの違いで案の定使えなかったりする。どうしても使ってみたいけど、サーバサイドAPIの仕様まで変えたくない場合、若干無理矢理な方法である程度なら対応させることができる。

サーバサイドの仕様

前提として以下のような仕様だとする

エントリリスト取得

GET /api/entries

# Response
{
  "ok" : true,
  "has_more" : true,
  "entries" : [ ... ]
}

データの新規作成

POST /api/entries
Content-Type: application/x-www-form-urlencoded

title=xxx&body=yyy

# Response
{
  "ok": true
  "entry" : { ... }
}

データの編集

PUT /api/entries?id=0
Content-Type: application/x-www-form-urlencoded

title=xxx&body=yyy

# Response
{
  "ok": true
  "entry" : { ... }
}

ngResource での対応

いくつかハマりポイントがある

  • AngularJS は POST 時のデフォルト Content-Type が application/json
  • ngResource は直接配列のJSONが返ってくることを前提にしている
    • そして付属するデータをうまく返す方法がない

いろいろやってみると以下のようになった。

var Entry = $resource('/api/entries', { id : '@id' }, {
	'query':  {
		method:'GET',
		isArray: true,
		transformResponse : function (data, headers) {
			data = angular.fromJson(data);
			if (!data.ok) throw "API failed";
			Entry.hasMore = data.has_more;
			return data.entries;
		}
	},
	'save':  {
		method:'POST',
		transformResponse : function (data, headers) {
			data = angular.fromJson(data);
			if (!data.ok) throw "API failed";
			return data.entry;
		},
		transformRequest: function (data, headers) {
			var ret = '';
			for (var key in data) if (data.hasOwnProperty(key)) {
				var val = data[key];
				ret += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(val);
			}
			return ret;
		},
		headers : {
			'Content-Type' : 'application/x-www-form-urlencoded'
		}
	},

	'update' : {
		method: 'PUT',
		transformResponse : function (data, headers) {
			data = angular.fromJson(data);
			if (!data.ok) throw "API failed";
			return data.entry;
		},
		transformRequest: function (data, headers) {
			var ret = '';
			for (var key in data) if (data.hasOwnProperty(key)) {
				var val = data[key];
				ret += '&' + encodeURIComponent(key) + '=' + encodeURIComponent(val);
			}
			return ret;
		},
		headers : {
			'Content-Type' : 'application/x-www-form-urlencoded'
		}
	}
});
  • 自力で transformResponse, transformRequest で ngResource が要求しているフォーマットに変更してやる
  • リストに付随するデータはスタティックに持たせてしまう (リクエスト直後に読み出すことを想定)
  • 自力で application/x-www-form-urlencoded なリクエストを作ってやる
  • 冗長に書いてるけど PUT と POST はメソッドが違うだけ

これを使う場合、

var entries = Entry.query(function () {
    $scope.hasMore = Entry.hasMore;
});

...

var entry = entries[0];

entry.title = "FooBar";
entry.$update();

みたいになる。だいぶアホっぽいし、この部分のコードがカオスになるけど、一応使えるようにはなる。

もっといい方法があったら教えてください……