はじめに
以前のこっちの記事「テストフレームワークを利用する場合に気を付けること(コード設計)」で、UT(ユニットテスト)でテストフレームワークを利用するには「差替えたい関数はStrategy パターン にしておく」と容易、って書いた。けれど、これも不足に思った。
で、今となっては「以下のような方針でコード設計すれば良いかなー」と思うようになった、って話。
- どのデザインパターンを使うのが良い、という話ではなく、単純に「テスト対象の関数から呼ばれる外部関数の全てをフック可能にしておく」でOKじゃん。
- コレの実現方法の1つがStrategyパターン。全てにStrategyを適用する必要は無い。
- 他にはFactoryパターン、Singletonっぽいもの?、での実装も有用。
- テスト対象の関数は、極力戻り値を返しましょう。特にPromise利用してる関数は、Promiseインスタンスのリターン必須。
- (アタリマエの事かもだが、最終処理だとウッカリ忘れることあってねw)
コード設計の方針
ユニットテストしやすいコード設計としては、以下のような感じにするとテストが楽なのかなぁ、と結論付けた。皆様はどんな視点で設計されているもんでしょう?
- その関数が呼び出す関数(同一ソース内での定義、標準関数のように外部で定義済み、を問わず)を明確にする。
- 上記の関数(のインスタンス)を差し替えられる仕組みを作る。
- 必須ではないが、その仕組みが有効なのはユニットテスト時のみ、とするのが望ましい。環境変数など利用して分岐させる。
- 例)JavaScript言語ならGetter/Setterを用意。
- 例)C/C++言語なら関数ポインタでwrapperする。
- (Promise利用可能環境で)非同期ならば、Promiseインスタンスを返却する。
- コレが無いと、テストに呼び出した関数の実行完了が取れないw
※ここまでは言語に依存しない話。
以降は、具体的コードサンプルの話なので、JavaScript(Node.js環境)に限定して書く。
(・・・FacotryパターンとSingletonパターンが混ざってて「オマエ、ちゃんと理解できて無いだろ?」でしたら、スミマセン)
コードサンプル。mssqlモジュール利用してQuery発行する関数の場合
テスト対象の関数は、getListOfBatteryLogWhereDeviceKey() で、非同期実行。
利用する外部関数は、mssqlモジュール。
テスト内容は、「入力したパラメーターにたいして、意図したSQLコマンドが発行されるか?」とする。
異常系テストは、このサンプルでは省略。
利用パッケージは以下。
npm install mssql --save
npm install mocha chai sinon promise-test-helper --save-dev
被テストコードは src フォルダに入れて、テストコードは test フォルダに入れてある。
Getter/Setterによる実装例
外部関数であるmssqlのインスタンスを差し替えられるようにGetter/Setterを実装しておく。
var mssql = require('mssql');
var _get = {}; // UTデバッグ用のHookポイント。
// if( 開発環境ならば ){ ~ }などとする。
exports.get = _get;
_get[ "mssql" ] = mssql;
var _set = {}; // UTデバッグ用のHookポイント。
// if( 開発環境ならば ){ ~ }などとする。
exports.set = _set;
_set[ "mssql" ] = (value)=>{ mssql = value; };
/**
* デバイス識別キーに紐づいたログを、指定されたデータベースから取得する。
* @param{String} Database データベース名
* @param{String} deviceKey デバイスの識別キー
* @returns{Promise} SQLからの取得結果を返すPromiseオブジェクト。成功時resolve( recordset ) 、失敗時reject( err )。
*/
var getListOfBatteryLogWhereDeviceKey = function( databaseName, deviceKey ){
var sql_request = new mssql.Request();
var query_str = "SELECT created_at, battery FROM [" + databaseName + "].dbo.tablename";
query_str += " WHERE [target]='" + deviceKey + "'";
return sql_request.query( query_str );
};
exports.getListOfBatteryLogWhereDeviceKey = getListOfBatteryLogWhereDeviceKey;
利用する外部関数が増えたら、以下のように増やしていけばよい。
// Getter/Setterの増やし方の例。
var func1 = require("func1.js");
_get[ "func1" ] = ()=>{ return func1; };
_set[ "func1" ] = (value)=>{ func1 = value; };
var func2 = require("func2.js");
_get[ "func2" ] = ()=>{ return func2; };
_set[ "func2" ] = (value)=>{ func2 = value; };
このコードに対するテストコードは以下になる。
mssqlのインスタンスをテスト環境用にフックしてスタブに差し替えている。
var chai = require("chai");
var assert = chai.assert;
var expect = chai.expect;
var sinon = require("sinon");
var shouldFulfilled = require("promise-test-helper").shouldFulfilled;
var shouldRejected = require("promise-test-helper").shouldRejected;
var api_sql = require("../src/api_sql_tiny_other1.js");
var DATA_BASENAME = "fake_db_name";
describe( "api_sql_tiny_other1.js", function(){
describe("::getListOfBatteryLogWhereDeviceKey()", function(){
var getListOfBatteryLogWhereDeviceKey = api_sql.getListOfBatteryLogWhereDeviceKey;
it("最低限のパラメータ(deviceKey)での表示", function(){
var stub_sql_request = {
"query" : sinon.stub()
};
var stub_mssql = {
"Request" : function(){
return stub_sql_request; // new mssal.Request() で返却されるインスタンスをコレに差し替える。
},
};
var EXPECTED_DEVICE_KEY = "ほげふがぴよ";
stub_sql_request.query.onCall(0).returns(
Promise.resolve()
);
api_sql.set.mssql( stub_mssql ); // ここでhookする。
return shouldFulfilled(
getListOfBatteryLogWhereDeviceKey(
DATA_BASENAME,
EXPECTED_DEVICE_KEY
)
).then(function(){
var EXPECTED_QUERY_STR = "SELECT created_at, battery FROM [";
EXPECTED_QUERY_STR += DATA_BASENAME;
EXPECTED_QUERY_STR += "].dbo.tablename WHERE [target]='";
EXPECTED_QUERY_STR += EXPECTED_DEVICE_KEY;
EXPECTED_QUERY_STR += "'";
assert( stub_sql_request.query.calledOnce, "query()が1度だけ呼ばれること" );
expect( stub_sql_request.query.getCall(0).args[0] ).to.equal(
EXPECTED_QUERY_STR
)
});
});
});
});
Singletonっぽいモノによる実装例
デザインパターンからすると、唯一のインスタンスとして利用するのであれば、Singletonパターンを適用すべき、とも考えられる。この場合は以下のようなコードだろうか?
わざわざ require() をラッピングするのが面倒にも思ったが、、、これはこれで明確に「こいつらが外部関数」って分かる点は利点か。先ほどのGetter/Setterでの実装とどちらが良いものだろうか?
// Factory(?)の定義は別ソースにして、共通利用すべきかな。
var Factory = function( moduleName ){
this.name = moduleName;
this.instance = null;
}
Factory.prototype.getInstance = function(){
if( !this.instance ){
this.instance = require( this.moduleName );
}
return this.instance;
};
// if( 開発環境ならば ){ ~ }などとする。
Factory.prototype._setStub = function( value ){
this.instance = value;
};
// require()を使う代わりに、new Factory() する。
var factoryImpl = {
"mssql" : new Factory("mssql")
};
exports.factoryImpl = factoryImpl;
/**
* デバイス識別キーに紐づいたログを、指定されたデータベースから取得する。
* @param{String} Database データベース名
* @param{String} deviceKey デバイスの識別キー
* @returns{Promise} SQLからの取得結果を返すPromiseオブジェクト。成功時resolve( recordset ) 、失敗時reject( err )。
*/
var getListOfBatteryLogWhereDeviceKey = function( databaseName, deviceKey ){
var mssql = factoryImpl.mssql.getInstance();
var sql_request = new mssql.Request();
var query_str = "SELECT created_at, battery FROM [" + databaseName + "].dbo.tablename";
query_str += " WHERE [target]='" + deviceKey + "'";
return sql_request.query( query_str );
};
exports.getListOfBatteryLogWhereDeviceKey = getListOfBatteryLogWhereDeviceKey;
利用する外部関数が増えたら、以下のように増やしていけばよい。この場合、Factory()
はGetter/Setterの両方の機能を持つので、増やしたときの追加コード量は先の例よりも少なくて済む。(※getInstance()
がGetterになる)
// Getter/Setterの増やし方の例。
var factoryImpl = {
"mssql" : new Factory("mssql"),
"func1" : new Factory("func1"),
"func2" : new Factory("func2")
};
// 以下の部分には、追加不要。
exports.factoryImpl = factoryImpl;
このコードに対するテストコードは以下になる。
mssqlのインスタンスをテスト環境用にフックしてスタブに差し替えているのは、同じ。
var chai = require("chai");
var assert = chai.assert;
var expect = chai.expect;
var sinon = require("sinon");
var shouldFulfilled = require("promise-test-helper").shouldFulfilled;
var shouldRejected = require("promise-test-helper").shouldRejected;
var api_sql = require("../src/api_sql_tiny_other2.js");
var DATA_BASENAME = "fake_db_name";
describe( "api_sql_tiny_other2.js", function(){
describe("::getListOfBatteryLogWhereDeviceKey()", function(){
var getListOfBatteryLogWhereDeviceKey = api_sql.getListOfBatteryLogWhereDeviceKey;
it("最低限のパラメータ(deviceKey)での表示", function(){
var stub_sql_request = {
"query" : sinon.stub()
};
var stub_mssql = {
"Request" : function(){
return stub_sql_request; // new mssal.Request() で返却されるインスタンスをコレに差し替える。
},
};
var EXPECTED_DEVICE_KEY = "ほげふがぴよ";
stub_sql_request.query.onCall(0).returns(
Promise.resolve()
);
api_sql.factoryImpl.mssql._setStub( stub_mssql ); // ここでhookする。
return shouldFulfilled(
getListOfBatteryLogWhereDeviceKey(
DATA_BASENAME,
EXPECTED_DEVICE_KEY
)
).then(function(){
var EXPECTED_QUERY_STR = "SELECT created_at, battery FROM [";
EXPECTED_QUERY_STR += DATA_BASENAME;
EXPECTED_QUERY_STR += "].dbo.tablename WHERE [target]='";
EXPECTED_QUERY_STR += EXPECTED_DEVICE_KEY;
EXPECTED_QUERY_STR += "'";
assert( stub_sql_request.query.calledOnce, "query()が1度だけ呼ばれること" );
expect( stub_sql_request.query.getCall(0).args[0] ).to.equal(
EXPECTED_QUERY_STR
)
});
});
});
});
最後に
ユニットテストのフレームワークの使い方、の説明はあちこちで見かけるが、テストコードをどう書くのか?設計の仕方は?って記載はあまり見かけないなーと。そう思って書いてみた。
最初はGetter/Setterを持つ設計推しだったのだが、外部関数(requireするモジュール)の数が増えてくると、Factocy/Singletonパターンで書いたほうが判りやすい気もしてきた。でも同一ソースファイル内で定義されている関数をhookする機構なら、Getter/Setterの方が楽か。・・・どっちがよいものかねぇ?
・・・あと。もしかして、「Factoryパターン、Singletonっぽいもの」って私が書いたもの、実は「まさにFactoryパターン、Singletonパターンだよ」ってオチだったりします?