はじめに
Node.jsでユニットテストをする場合に、どう設計するのが良いか?を言葉にしてみる。あくまで「私は、これが(今は)しっくりくる」という話。なおASTによるソース解析を含めたHook手法は、今回は行わないものとする。
こちらの記事「テストフレームワークを利用するときの、コード設計について」で書いた「テスト対象の関数から呼ばれる外部関数の全てをフック可能にしておく」と、結論としては同じ。
もう少しスッキリ書けるんじゃないかな?ってのがこの記事の目的。
あと「先にテストを書いてから実装」という話との関連も少し書く。
流れとしては、以下。
- こんな風に書くと、テスト時のローカル関数のフックが楽にできる。
- この書き方の元にテストを書くとこうなる(実装は無しでテストはFailする)。
- 実装するとこうなる。
【補足】
2018/07/10:サンプルコードへのリンク(GitHub)を追記しました。
コード設計の方針
- hookポイントを設ける。ローカル関数は全てこの配下に接続する。
- hookポイントを、開発環境のみ外部公開する。
- テスト対象の関数は極力戻り値を返す。非同期ならPromiseオブジェクトの返却必須。
たぶん、この3点でOK。
1番は、次のようなイメージ。
※ここで「外部関数」とは、被テスト関数からみて「外部」と言う意味で、同じソースファイル内のローカル関数、別ファイルの関数を区別しない。
2番は、気持ちの問題。本番環境では、余計なものは見えない方が良い。環境変数でOn/Offするのがたぶん簡単。
3番は、非同期の場合はPromiseオブジェクトを受け取れないと、動作の完了を知ることが出来ないのでテスト出来ない、から。
hookポイントを設けて、ローカル関数はすべてその配下に接続
例えばこんなコードを書こう、とした場合を考える。
var localApi1 = function (params) {
console.log("[enter localAPi1]: argv=[" + params +"]" );
};
var localApi2 = function (params) {
console.log("[enter localAPi2]: argv=[" + params +"]" );
};
exports.doSomething = function ( params ) {
console.log( "[enter doSomething]: argv=[" + params + "]" );
localApi1( "calls with" + params );
localApi2( "calls with" + params );
};
この場合は、以下のように書こう、と言う方針。
var hook = {}; // フック用のポイント
hook[ "localApi1" ] = function (params) {
console.log("[enter localAPi1]: argv=[" + params +"]" );
}
hook[ "localApi2" ] = function (params) {
console.log("[enter localAPi2]: argv=[" + params +"]" );
}
exports.doSomething = function ( params ) {
console.log( "[enter doSomething]: argv=[" + params + "]" );
hook.localApi1( "calls with" + params );
hook.localApi2( "calls with" + params );
};
テスト時には、このhook 配下を丸ごと差替えるので(後述)、「明示的に準備したstub関数以外が誤って呼び出されたら undefined 等でエラーする(失敗)」が期待できる。デグレードの検出。
hookポイントは開発環境でのみ公開する。
開発環境のみの動作を規定するには、環境変数を使うのが簡単。
var hook = {};
if( process.env.NODE_ENV == "development" ){
exports.hook = hook;
}
hook[ "localApi1" ] = function (params) {
console.log("[enter localAPi1]: argv=[" + params +"]" );
}
hook[ "localApi2" ] = function (params) {
console.log("[enter localAPi2]: argv=[" + params +"]" );
}
exports.doSomething = function ( params ) {
console.log( "[enter doSomething]: argv=[" + params + "]" );
hook.localApi1( "calls with" + params );
hook.localApi2( "calls with" + params );
};
テスト実行に、環境変数 NODE_ENV == development を設定するには、package.json で以下のように指定する。
"scripts": {
"test" : "cross-env NODE_ENV=development node_modules/.bin/mocha"
},
mochaはローカルインストールとしているので、node_modules/.bin/mocha で直に指定する。Windows環境での環境変数の指定は「set NODE_ENV=development
」とすべきだが、Linux環境の互換のため、cross-envを用いて上記のように指定する。
cross-envは「npm install cross-env --save-dev
」でローカルインストールしておく。通常、このcross-envでの環境変数の設定はテスト環境(開発環境)でのみ利用するので、devDependenciesへの記録すれば良い。(※この後の1つ目のサンプルでは、通常実行時に環境変数を設定するので、dependenciesへ記録している)
サンプルコードに対するフックの仕方(動作例)
上記で記載したコードを、以下のように呼び出して実行する。
/**
* [localFunctionHookSample.js]
* encoding=utf-8
*/
var calledApi = require("./calledApi.js");
calledApi.doSomething("hoge!");
すると、以下のように出力される。これは通常動作(フック無し)。
【enter doSomething】: param=[hoge!]
【enter localAPi1】: argv=[calls withhoge!]
【enter localAPi2】: argv=[calls withhoge!]
上記のサンプルコードにおいて、doSomething()の内部で呼び出されるローカル関数の動作をフックして差替えるには、以下のようにする。ここで、doSomething()を定義した calledApi.jsは、先に示した hook
ポイントを設けており、且つ次の動作は環境変数 NODE_ENV=development
を設定した環境で実施する。 「hook4test.js」配下で「hookProperty()」を定義しているが、これは後述する。
/**
* [localFunctionHookSample.js]
* encoding=utf-8
*/
var hookProperty = require("./hook4test.js").hookProperty;
var hook = calledApi.hook;
var hookedInstance = hookProperty( hook, {
"localApi1" : function () { console.log("[stub - localApi1]"); },
"localApi2" : function () { console.log("[stub - localApi2]"); }
} )
calledApi.doSomething("hoge!");
hookedInstance.restore();
上記のサンプルコードのように、別ファイルの hook 配下のlocalApi1(), localAPi2()
をフックした場合は以下のように出力される。2行目と3行目の出力が、フックして置き換えたスタブ関数の動作になっている。差替え成功!
【enter doSomething】: argv=[hoge!]
【stub - localApi1】
【stub - localApi2】
なお、上述のサンプルコードで利用した、hookProperty()の定義は以下。
/**
* [hook4test.js]
* encoding=utf-8
*/
var RESTORE_MANAGER = function ( targetObject, originalMaps ) {
this._targetObject = targetObject;
this._originalMaps = originalMaps;
// return this; ※明示せずとも動作としては変わらない。
};
RESTORE_MANAGER.prototype.restore = function () {
var original = this._originalMaps;
var targetObject = this._targetObject;
var keys, n;
// 設定済みのpropertyを全て削除。
keys = Object.keys( targetObject );
n = keys.length;
while (0<n--) {
targetObject[ keys[n] ] = undefined;
}
// 退避して置いたpropertyを接続し直す。
keys = Object.keys( original );
n = keys.length;
while(0<n--){
targetObject[ keys[n] ] = original[ keys[n] ];
}
return targetObject;
};
/**
* 指定されたオブジェクトのプロパティを全て退避&undefinedに設定した後に、
* 指定したstubのプロパティを接続し直す。
*
* @param {*} targetObject プロパティを差し替えるオブジェクト。
* @param {*} stubPropertyMap 差換え後のオブジェクト。これが接続される。
*/
var hookProperty = function ( targetObject, stubPropertyMap ) {
var originalMaps = {};
var stubed = targetObject;
var keys = Object.keys( targetObject );
var n = keys.length;
// オリジナルのpropertyを退避する
while(0<n--){
originalMaps[ keys[n] ] = targetObject[ keys[n] ];
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_Operators
// delete演算子も検討したが「意図しないプロパティが居る」ことが分かるように、indefined代入とした。
targetObject[ keys[n] ] = undefined;
}
// スタブとして渡されたpropertyへ差換える。
keys = Object.keys( stubPropertyMap );
n = keys.length;
while(0<n--){
stubed[ keys[n] ] = stubPropertyMap[ keys[n] ];
}
return new RESTORE_MANAGER( targetObject, originalMaps );
};
exports.hookProperty = hookProperty;
実際に動作するサンプルコードと実行方法
以上で述べた「通常時」+「差し替え時(フックあり)」の、サンプルコードはこちら(※sample\just-hookフォルダ配下)。
https://github.com/hoshimado/hook-test-helper/tree/v0.0.2-gather-sample-and-remove-from-npm
実行手順は以下。
- 「Clone or download」からDownload ZIPして保存、展開。
- sample\just-hookフォルダ配下へ移動。
- just-hookフォルダ上でコマンドラインから以下を実行。
npm install
npm start
※installコマンドを実行するのは、hook-test-helper, cross-env を利用しているため。
※hook-test-helperは、上記のhookProperty()辺りを定義したパッケージ。
※npm start
には「NODE_ENV=development」設定済み。
※node index.js
を直接実行すると、NODE_ENV無しでの動作がになる。当然ながらhookポイントが外部公開されていないので、hookProperty() 操作のところでエラーに成る。
テストの方針
たとえば、こんなコードの実装を考えるとする。
外部から呼び出されるのは、method1() のみで、その他の open(), doSomething(), close()はローカル関数。
なお、下記の時点ではまだ何も実装していないし、機能の設計もしていない、ことに留意。枠だけ作った。非同期動作の例を使いたかったので、Promise返却している。
/**
* [sample_design.js]
* encoding=utf-8
*/
var createHookPoint = require("hook-test-helper").createHookPoint;
var hook = createHookPoint(exports, "hook");
hook["open"] = function(){
return Promise.reject();
};
hook["doSomething"] = function () {
return Promise.reject();
};
hook["close"] = function() {
return Promise.reject();
};
exports.method1 = function(keyword) {
return Promise.reject();
};
ここで、「createHookPoint()」は、先の「exports.hook = hook;」あたりを別関数へ抽出したもので、定義は以下(npmのパッケージhook-test-helperとして登録済み)。
/*
[hook4src.js]
encoding=utf-8
*/
/**
* 環境変数 NODE_ENV==development の時に、指定した変数名で「外部公開設定済み」の変数を返却する。
*
* @param {*} exportsInstance Modules.exports を指定する
* @param {String} variableName 外部公開するHookポイントの変数名を文字列で指定する。
* @param {*} existInstance 設定するインスタンス。省略した場合は、内部でオブジェクト{}を自動生成する。
*/
var createHookPoint = function ( exportsInstance, variableName, existInstance ) {
var instance = (existInstance) ? existInstance : {};
if( process.env.NODE_ENV == "development" ){
exportsInstance[ variableName ] = instance;
}
return instance;
};
exports.createHookPoint = createHookPoint;
テストコードで、利用するローカル関数をスタブで準備する
上記のコードの設計を進める。先ずはmethod1()の機能を「open()を呼んで、その戻り値をもとに doSomething() を実行して、最期に close()する」のように設計したとする。これをつとコードで書くと以下のようになる(Mocha+Chai+Sinon+promise-test-helperを使っている)。
/*
[sample_design_test.js]
encoding=utf-8
*/
var chai = require("chai");
var expect = chai.expect;
var assert = chai.assert;
var sinon = require("sinon");
var promiseTestHelper = require("promise-test-helper");
var shouldFulfilled = promiseTestHelper.shouldFulfilled;
var hookProperty = require("hook-test-helper").hookProperty;
var target = require("../sample/sample_design.js");
describe("TEST for sample_design.js", function(){
var method1 = target.method1;
var stubbed = {};
var stubs;
beforeEach(()=>{ // フック前の関数を保持する。
stubs = {
"open" : sinon.stub(),
"close" : sinon.stub(),
"doSomething" : sinon.stub()
};
stubbed["hook"] = hookProperty(target.hook, stubs);
});
afterEach(()=>{ // フックした(かもしれない)関数を、元に戻す。
stubbed.hook.restore();
});
describe("method1()",function(){
it("do something with open and close.", function(){
var keyword = "dummy";
var HANDLE = "STUB HANDLE";
var EXPECTED_RESULT = "fake result";
stubs.open.onCall(0).returns(
Promise.resolve(HANDLE)
);
stubs.doSomething.withArgs(HANDLE).returns(
Promise.resolve(EXPECTED_RESULT)
);
stubs.close.onCall(0).returns(
Promise.resolve()
);
return shouldFulfilled(
method1(keyword)
).then(function (result) {
assert(stubs.open.calledOnce, "open() is called.")
assert(stubs.doSomething.calledOnce, "doSomething() is called.")
assert(stubs.close.calledOnce, "close() is called.")
expect(result).to.equal(EXPECTED_RESULT);
});
});
});
});
doSomething()から呼び出されるローカル関数は、全て stubs = {} で定義して、先に利用した hookProperty() をここでも用いて差替えている。
上記のテストコードを実行すると、doSomething()は「reject()」を返しているので、当然ながらテストは「失敗」する。
テスト対象の関数だけを実装する(他は未実装のままでもテストは「成功」する)。
被テストコードの「sample_design.js」を、以下のように実装すると、このテストは「成功」するようになる。
/**
* [sample_design.js]
* encoding=utf-8
*/
var createHookPoint = require("hook-test-helper").createHookPoint;
var hook = createHookPoint(exports, "hook");
hook["open"] = function(){
// ここは未実装。
return Promise.reject();
};
hook["doSomething"] = function () {
// ここは未実装。
return Promise.reject();
};
hook["close"] = function() {
// ここは未実装。
return Promise.reject();
};
// これだけ実装。
exports.method1 = function(keyword) {
var value = {};
return hook.open(keyword).then(function (handle) {
return hook.doSomething(handle);
}).then(function (result) {
value = result;
return hook.close();
}).then(function(){
return Promise.resolve(value)
});
};
今回に実装したのは、doSomething()のみで、その中で呼び出される open()などは未実装のまま。doSomething()のテストとしては、そちらは知ったことではないので、これでOK。後でで、open()その他も別途テストを書いて機能を設計して、実装していく。
以上で、「ローカル関数のフックして、目的の関数の動作だけをテストする」が出来た。
sample_design.jsに対するテスト実行のサンプルコード
sample_design.jsとsample_design_test.jsの実際のコードはこちら(※sample\demo-test-hookフォルダ配下)。
https://github.com/hoshimado/hook-test-helper/tree/v0.0.2-gather-sample-and-remove-from-npm
実行手順は以下。
- 「Clone or download」からDownload ZIPして保存、展開。
- sample\demo-test-hookフォルダ配下へ移動。
- demo-test-hookフォルダ上でコマンドラインから以下を実行。
npm install
npm test
※installコマンドを実行するのは、テストフレームワークmochaその他を利用しているため。
※npm test
には「NODE_ENV=development」設定済み。
※npm start
は未設定。
参考リンク
Mocha
https://mochajs.org/
sinon
http://sinonjs.org/
JavaScript Promiseの本>promise-test-helper
http://azu.github.io/promises-book/
テストフレームワークを利用する場合に気を付けること(コード設計)
https://qiita.com/hoshimado/items/531a0b9a4cdc30e7d087
sinonの使い方、使う場面(SpyとStub)
https://qiita.com/hoshimado/items/da9f814ce4bdf8f80fa1