前回はサーバとビュー向けのインタフェースについて紹介しました。今回はテストについて紹介します。
僕が書いたMithril本では、sinonを使ってサーバレスポンスをエミュレーションしてテストする方法を紹介しました。しかし、僕はモックが嫌いです。「8.3.2モックを使ったテストの注意点」にも書きましたが、モックを書く行為はテストのためのコーディングで、テストのテストを書くのは大変という問題に足をすくわれることが多いです。特に、サーバレスポンスがすでに決まっている時はいいのですが、フロントとサーバが同時進行の場合で形式も決めなければいけない、というケースでモックを使うと、確実に生え際が後退します。
サーバ側の設計が固まってない場合の設計方法
サーバがまだで、インタフェースだけ先に開発したい場合は、デバッグ用のクチを用意しておくと便利です。サーバインタフェースが先にできあがっている場合はそれに合わせて入出力変換を作っていけばいいですが、逆にクライアント先行の場合は、デバッグインタフェースを元にJSONフォーマットを決め、サーバがそれに合わせる、という開発も可能です。
Mithrilの場合は単一ステートというわけではなく、コンポーネントごとにモデルを持ってきて(推奨はモデル直接ではなくビューモデルを持たせて、そのビューモデルがサーバにアクセスする)ロードさせることもできます。そのあたりは結構自由です。
サーバインタフェースがすでに固まっていて、モデルレイヤーの実装が最初からばっちり決まるのであればもちろんそうするのがいいのですが、サーバAPIも同時に設計していく場合は、最初は効率とかは考えずに、部品ごとにデータを持ってくるようにして、徐々に親コンポーネントにデータアクセス部分を移動していく、というステップで改良していくのが個人的には好きです。
- まずはコンポーネントごとにモデルを作り、ダミーのデータを使ってテストを通しつつ、機能を確定させる
- 似たようなデータを利用するコンポーネントが複数あるのであれば、ダミーデータを統合し、同じJSONを元に両方のコンポーネントがそれぞれ期待するようにコードを修正していく
- 親子関係があるコンポーネントで、子のコンポーネントでデータをロードしている部分があれば徐々に親側にデータロード部移動していく。子コンポーネントの引数でJSONを引き回す設計にしていく
Mithrilのビューは必要なときまで描画を遅延させてくれるので、動くサービスコードを書く分には問題ありませんが、複数の階層にまたがっているコンポーネントで、子供のコンポーネントのロードが確実に完了しているかを親コンポーネントが知る術はありません。そのため、テストコードを書く時に問題になります。
テスト向けインタフェース
テスト用のデータをsetDummyData()
で差し込めるようにしています。このメソッドでテストデータを投入すると、サーバへのリクエストを行わずに、データのロードが行えるようになります。データのロードがコントローラ内で行われたとしても、問題ありません。
var _dummyData = null;
class Todo {
// 単体のインスタンスをサーバから取得
static load(id) {
if (_dummyData) {
const deferred = m.deferred();
setTimeout(() => {
for (var i = 0; i < _dummyData.length; i++) {
if (_dummyData[i].id === id) {
deferred.resolve(new Todo(_dummyData[i]));
return;
}
}
deferred.reject(new Error('not found'));
}, 0);
return deferred.promise;
}
// あとでかく
}
// インスタンスのリストをサーバから取得
static loadByList() {
if (_dummyData) {
const deferred = m.deferred();
setTimeout(() => {
deferred.reoslve(_dummyData.map(data => {
return new Todo(data);
});
}, 0);
return deferred.promise;
}
// あとでかく
}
static setDummyData(data) {
_dummyData = data;
}
}
本番コードの例は前回の記事にある通り、m.request()
を使います。m.request()
はサーバからのレスポンスが来たら応答するPromiseを返しますので、同じインタフェースにしています。
非同期を使ったテストの書き方
テストコードでは、非同期の場合はテスティングフレームワークにその旨を伝達しないと困ったことになります。具体的には、Promiseをreturnするようにすれば、そのPromiseが解決されるまでは次のステップに進まないようになります。
const dummyData = [
{id: 1, description: "ウルトラマンオーブを録画する"},
{id: 2, description: "幼稚園に着ていける黒/紺のカーディガンを買う"},
{id: 3, description: "サンタに子供からの要望を伝達する"},
];
describe('Todo', () => {
before(() => {
Todo.setDummyData(dummyData);
});
it('can load instance', () => {
return Todo.load(1)
.then(instance => {
assert(instance.description() === 'ウルトラマンオーブを録画する');
});
});
after(() => {
Todo.setDummyData(null);
});
});
ロードをbeforeでやる場合も同じです。returnで返せばOK。返さなかった場合には、テスティングフレームワークが「どのテストケースでエラーが発生したのか?」を誤解して大変なことになります(Mochaの場合)。どのテストでエラーになったのかが分からないため、テストのファイルを一つずつ実行したり、テストをコメントアウトして、問題のテストケースを見つけることになります。
たいていはreturnがないのが問題なので、上記のように非同期呼び出しをするメソッド名のprefixをload
に統一している場合は、その名前でgrepすれば見つかりやすいかと思います。
コンポーネントのテスト
Mithril本体はデータのロードを待つ仕組みがあるので問題ないのですが、ユニットテスト等ではその待つ仕組みを利用することはできません。また、モデルであればロードしているところが比較的浅くにあるため、フックするのは難しくないでしょう。
コンポーネント、あるいはモデルを使うビューモデルでは、モデルのロードが奥底に隠れてしまうため、テスト時に適切な状態が入ったビューの結果をテストするには一工夫必要です。
今のところはモデルを使ってサーバからロードする必要があるコンポーネントのコントローラやビューモデルに、ロードが完了したフラグを持たせるようにしています。loaded
という名前をここでは使うことにします。
class ViewModel {
constructor() {
this.todos = m.prop([]);
// ロードが完了したら、this.todosにデータが入る
// loadedは終了時に起動するPromise
this.loaded = Todo.loadByList().then(this.todos);
}
}
const Component = {
controller: function() {
this.vm = new ViewModel();
// 親は子供のloaded、あるいは複数あればそれをm.syncでまとめたものを、
// 自分のloadedに持たせる
this.loaded = m.sync([this.vm.loaded]);
},
view: function() {
...
}
};
テストではこのloadedを使うことでロード待ちを確実に行えますし、テストがおかしくなることはありません。
const mq = require('mithril-query');
describe('Component', () => {
var controller;
beforeEach(() => {
Todo.setDummyData(dummyData);
controller = Component.controller();
// loadedを返すと、ロード完了まで待つ
return controller.loaded;
});
it('can show todo item', () => {
// Mithril Queryに生成済みのビューを渡す
const vdom = mq(Component.view(controller));
assert(vdom.has('span.todo'));
});
it('can show todo item 2', () => {
// もちろん、beforeで初期化しないで、各テストケースで初期化も一緒にやることは可能
const controller = Component.controller();
return controller.loaded.then(() => {
const vdom = mq(Component.view(controller));
assert(vdom.has('span.todo'));
});
});
after(() => {
Todo.setDummyData(null);
});
});
親子関係になっているコンポーネントでも、内部でモデルアクセスしている場合があります。その場合は、テストの時はエラーにならないように、ロードが完了していなければ何もしないというロジックを入れるか、コンポーネント内部でロードする必要があるビューモデルを外部からインジェクションするようにするといった対処が必要になるでしょう。