概要
Javscriptでクラスのメソッドをテストしようとしたときにthisの参照で失敗しました。
callを使うことでthisを参照するクラスのメソッドもテストすることができたので、やりかたをまとめました。
問題
以下の通り、test関数と被テスト用のTestedクラスを定義します。
//テスト用関数
function test(func, arg, expected){
const result = func(arg);
if(result === expected){
console.log("OK");
}
else{
console.log("FAIL");
}
}
//被テストクラス
class Tested {
constructor(){
this.x = 3;
}
getX(){
return this.x;
}
}
testはテストされる関数(func)、テストされる関数に代入する引数(arg)、期待する値(expected)を引数として、funcにargを代入した結果とexpectedを比較した結果をコンソールに出力します。(なお、このテスト関数は2つ以上引数を取る関数に対応していなかったり、objectを出力する関数のチェックがうまくできなかったりして汎用性が低いのですが、本質ではないので、今回は気にしないことにします)
Testedクラスはthis.xの値を返すgetXメソッドをもちます。this.xにはコンストラクタで3が代入されているので、getXは原則3を返します。
let tested = new Tested();
console.log(tested.getX() === 3);
//true
一方で、tested.getXの戻り値が3であるかをtest関数で調べると、OKが出力されることが期待されますが、以下のエラーが返ってきます。
test(tested.getX, null, 3);
//Uncaught TypeError: Cannot read property 'x' of undefined
// at getX (<anonymous>:16:17)
// at test (<anonymous>:2:18)
// at <anonymous>:22:1
原因
getXのreturn this.xの行で、「undefinedのxを読めない」と言われているので、thisがundefinedとなっていることが原因だとわかります。
thisの仕様が難しくてすべてを理解できてはいないのですが、どうもtestの引数としてtested.getXを入れてしまったので、getXが参照するthisがtestedインスタンスではなく、test関数になってしまい、xの参照がうまく機能しないようです。
追記:undefinedになる理由がよく割らなかったのですが、関数が参照するthisはStrictモードではundefinedになるという仕様があるらしく、どうもそれっぽいです。
(参考:JavaScriptのthisの挙動を完全に理解した話 )
解決方法
Function.prototype.call()を使うことで、クラスメソッドのテストを正しく行うことができました。
//tested.getXをfunction(_arg){return tested.getX.call(tested, _arg)}に置き換える
test(function(_arg){retrun tested.getX.call(tested, _arg)}, null, 3)
// OK
Function.prototype.callは関数がthisとして扱うべき変数を第一引数に指定することができます。
今回はtestedをthisとして扱ってほしいので、tested.getX.call(tested, _arg)とすればよいです。
ただしtested.getX.callをそのままtest関数の引数に持ってくることはできないので、_argだけを引数としてとる無名関数でラップします。
参考:
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Function/call
おまけ
test関数が複数引数を扱えるように拡張しておくことで、callを無名関数でラップしなくてもよくなりコード量を削減できるかも、、、と思ったのですが、callを引数に指定するとどうもうまく機能しないようです。
//引数はfunc, arg1,arg2, ..., expectedのように指定する
function test(){
//複数引数に対応できるように、argumentsで引数を取得する
const _arguments = [].slice.call(arguments);
const func = _arguments[0];//最初の引数
const args = _arguments.slice(0,-1);//最初と最後以外の引数
const expected = _arguments[_arguments.length-1]//最後の引数
const result = func(...args); //...で引数のリストを展開する
if(result === expected){
console.log("OK");
}
else{
console.log("FAIL");
}
}
//さっきまでと一緒
class Tested {
constructor(){
this.x = 3;
}
getX(){
return this.x;
}
}
let tested = new Tested();
//callを使う分、引数がひとつ増えた扱いにして、テストする
test(tested.getX.call, tested, null, 3);
//VM352:9 Uncaught TypeError: func is not a function
// at test (<anonymous>:9:18)
// at <anonymous>:1:1
callを別変数に代入して関数のように扱うことはできないようです。callはprototypeのメソッド(?)として定義されますが、別変数に代入した時点でprototypeの参照先が変わってしまうためかもしれません(よくわかっていません)
おまけ2
thisクラス側を書き換えることが可能な状況であれば、アロー関数を使ってメソッドを定義すれば、thisに悩まされなくて済みそうです。
今まであまり良くわかっていなかったのですが、functionとアローの違いはこういうところにあるんですね。
class Tested {
constructor(){
this.x = 3;
}
getX = () => {
return this.x;
}
}
let tested = new Tested();
console.log(test(tested.getX,3));
//true