概要
JavaScriptのソースを単体テストする際、Jestを使っている人も多いと思います。
で、ある関数のテストをする際に、その関数が呼ぶ別の関数を一時的にmock関数にしたい場合があります。
Jestでは、それをspyOn()というメソッドを使うことで実現できます。
// 例:例えばこのvideo.play()関数自身はtrueを返すけど... const video = { play() { return true; }, }; module.exports = video;
const video = require('./video'); test('plays video', () => { // こんな感じでspyOn()してあげることで、mock関数化して、 // そのふるまいを自由に定義できる。 // ここではfalseを返すように変更している const spy = jest.spyOn(video, 'play').mockReturnValue(false); const isPlaying = video.play(); // 上でfalseを返すように変更しているので、このテストはNGになる。 expect(isPlaying).toBe(true); spy.mockRestore(); });
が、spyOn()を実行してもmock関数化されないという現象がありましたので、その対処法についてが今回の内容です。
具体的な現象
下記のソースがあるとします。
// ただnumが偶数化奇数かを判定するだけのもの。 // 仮にファイル名をevenOrOdd.jsとする。 // このmain関数をテスト対象とする。 const main = num => { const modNum = getEvenOrOdd(num); const resultString = modNum === 0 ? "even" : "odd"; return resultString }; const getEvenOrOdd = num => { return num % 2; } // module.exports宣言 module.exports = { main: main, getEvenOrOdd: getEvenOrOdd }
で、main関数の単体テストとして、下記のテストを書いたとします。
コメントにある通り、このテストは本来NGになるはずです。
const target = require('./evenOrOdd'); test('numが偶数の時、文字列evenを返すこと', () => { // ここではspyOn()を使用し、getEvenOrOdd()は必ず1を返すようにする。 const spy = jest.spyOn(target, 'getEvenOrOdd').mockReturnValue(1); const result = target.main(2); // 上でgetEvenOrOdd()は必ず1を返すように変更しているので、 // このテストはNGになるはず。 // (本来なら必ず文字列oddを返すはず) expect(result).toBe("even"); console.log("result is " + result); spy.mockRestore(); });
ところが、上のテストはOKになってしまいます。(実際ログで確認しても、resultの値はevenになっています。)
こんな感じで、spyOn()が作用しない(=実際の関数を実際に呼んでしまう)状況があります。
原因と対処法
【参考サイト】:Jestで関数から呼ばれてる関数をspyOnする - Qiita
原因ですが、参考ページの「Jestで関数から呼ばれてる関数をspyOnする - Qiita」にある通り「スコープが違うから」です。
上記のソースだと「module.exportsしたgetEvenOrOddと、main関数が呼ぶgetEvenOrOddは別物」という扱いなので、spyOnしてもダメみたいです。
対処法ですが、「同じスコープで扱えるように、getEvenOrOddをオブジェクトの要素として扱う」になります。
const main = num => { // getEvenOrOddを呼ぶ側は、オブジェクトを介して呼ぶ。 // オブジェクトを介することで、Jestでmainをテストする際も // オブジェクト経由でgetEvenOrOddを認識できるようになる。 const modNum = self.getEvenOrOdd(num); const resultString = modNum === 0 ? "even" : "odd"; return resultString }; const getEvenOrOdd = num => { return num % 2; } // こんな感じで、getEvenOrOddをオブジェクト(self)の要素にする const self = { getEvenOrOdd : getEvenOrOdd }; // module.exports宣言 module.exports = { main: main, // getEvenOrOdd関数自体ではなく、それを持つオブジェクトをexportする。 self: self }
const target = require('./evenOrOdd'); test('numが偶数の時、文字列evenを返すこと', () => { // テスト側もオブジェクト(targer.self)経由でgetEvenOrOdd()をspyOnする。 const spy = jest.spyOn(target.self, 'getEvenOrOdd').mockReturnValue(1); const result = target.main(2); // 上でgetEvenOrOdd()は必ず1を返すように変更しているので、 // このテストはNGになるはず。 // (本来なら必ず文字列oddを返すはず) expect(result).toBe("even"); console.log("result is " + result); spy.mockRestore(); });
上記テストソースを実行すると、確かに今度はspyOn()が作用して、テストはNGとなりました。(ちゃんとoddが返ってます)
thisを使った方法
ちなみに「this」を使用すれば、わざわざオブジェクトを作成&exportしなくても、spyOnは正常に動作します。
ただし「thisが自分自身(=evenOrOdd.js)を指すかどうか」については、要注意。(特にアロー関数を使っている場合など)
function main(num) { // thisを介してgetEvenOrOddを呼んでもOK。 const modNum = this.getEvenOrOdd(num); const resultString = modNum === 0 ? "even" : "odd"; return resultString }; function getEvenOrOdd(num) { return num % 2; } // module.exports宣言 module.exports = { main: main, getEvenOrOdd: getEvenOrOdd, }
const target = require('./evenOrOdd'); test('numが偶数の時、文字列evenを返すこと', () => { // thisがあることでmain関数はgetEvenOrOdd()を認識可能なので、 // spyOnできる。 const spy = jest.spyOn(target, 'getEvenOrOdd').mockReturnValue(1); const result = target.main(2); // 「原因と対処法」同様、このテストは正しくNGになります。 expect(result).toBe("even"); console.log("result is " + result); spy.mockRestore(); });
exportsを使う場合
なお「module.exports」ではなく、各関数を「exports」する場合でも、thisを使うことで別関数を呼び出せるし、テスト時にspyOnも実行できます。
exports.main = function(num) { // 各関数を個別にexportsする場合でも、thisを使って // 別関数を呼び出せる。 // もちろんテスト関数でspyOn()もできる。(結果は同じなので省略) const modNum = this.getEvenOrOdd(num); const resultString = modNum === 0 ? "even" : "odd"; return resultString }; exports.getEvenOrOdd = function(num) { return num % 2; }
で、どっちがいいの? ※以下、個人的な感想です
【参考サイト】:Node.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiita
ここまでspyOnするための関数のexport方法として
- 関数を内包したオブジェクトのexport
- thisを使って、関数を個別にexport(module.exportsにしろ個別exportにしろ)
の2つを紹介しましたが、個人的には前者が良いと思っています。
理由としては、以下の通りです。
thisが指す内容
先述の通り、thisが関数の書き方(アロー関数を使うか否か)によって、意図しないものを指す場合があります。
それをちゃんと分かっていればいいんですが、知らずにやってしまうと、思わぬバグのもとになるからです。
ちなみに、Vue.jsでは上記の挙動のため、一部の関数(computedやライフサイクルメソッド)で「アロー関数を使用すべきではない」と書かれています。
不要な関数を公開しない
今回の「getEvenOrOdd」関数はprivateな関数なので、テスト用途以外で公開する必要はありません。
こういう「公開不要なもの」は、極力公開すべきではないと考えています。(特に本番環境などでは)
関数をオブジェクトで内包するやり方だと、公開する/しない制御が簡単にできるからです。
このあたりの考え方については、参考サイトのNode.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaにも記載されていますし、僕も過去に【AWS】単体テストを考慮したLambdaの構成を考えた - echo("備忘録"); でブログにしています。
const main = function(num) { const modNum = self.getEvenOrOdd(num); const resultString = modNum === 0 ? "even" : "odd"; return resultString }; const getEvenOrOdd = function(num) { return num % 2; } const self = { // getEvenOrOddをオブジェクトで内包する getEvenOrOdd : getEvenOrOdd }; // module.export用オブジェクト。 // mainはpublicな関数なので、無条件で公開する。 const exportsFunc = { main: main }; // self(evenOdOdd)はprivateな関数なので、開発環境のみ // テスト用途で公開する。 if (process.env.ENVIRONMENT === 'dev') exportsFunc.self = self; module.exports = exportsFunc;
spyOnしなくても関数をmock化できる
関数をオブジェクトで内包する場合、その関数はあくまで「オブジェクトのキーの値」です。
なので、テスト内でオブジェクトのキーの内容を書き換えることで、強制的に関数の内容を変更することができます。
つまり、spyOnを使用しなくても、テスト時に関数のmock化を行うことができますので、どうしてもspyOnがうまく動かない場合も、関数のmock化が可能になります。
参考サイトのNode.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaでも紹介されていますね。(てか、僕もそれを参考にさせてもらったわけですが)
const target = require('./evenOrOdd'); let mockFuncCache = null; beforeAll(() => { // テスト開始前に、self.getEvenOddの内容 // (=evenOrOdd.jsのgetEvenOdd関数の内容)をコピーしておく mockFuncCache = Object.assign({}, target.self).getEvenOrOdd; }); afterEach(() => { // 各テスト終了後に、self.getEvenOrOddの内容をevenOrOdd.jsの内容に戻す。 target.self.getEvenOrOdd = mockFuncCache; }) test('numが偶数の時、文字列evenを返すこと', () => { // 最初のテストでは、self.getEvenOrOddを書き換えて、必ず1を返すようにする。 target.self.getEvenOrOdd = function() { return 1; }; const result = target.main(2); // self.getEvenOrOdd()は必ず1を返すので、 // このテストはNGになるはず。 expect(result).toBe("even"); console.log("result is " + result); }); test('numが偶数の時、文字列evenを返すこと', () => { // 2回目のテストでは、self.getEvenOrOddの内容は書き換えない。 // 1回目テスト終了~2回目テスト開始の間にafterEach()を通るので、 // self.getEvenOrOddの内容はevenOrOdd.jsのものと全く同じ。 const result = target.main(2); // 今回はself.getEvenOrOdd()はnum=2なら0を返すので、 // このテストはOKになるはず。 expect(result).toBe("even"); console.log("result is " + result); });
上記テストの結果がこちら。
期待通り1回目はNG、2回目はOKになっています。
このことからも、自分で書き換えた内容が正しく反映されているのがわかります。
まとめ
以上、SpyOnで関数のMock化ができない場合の対処、およびさらに踏み込んでexportする際の考え方について書きました。
確かにSpyOnで関数のMock化ができない場合の対処だけならともかく、さらに一歩踏み込んだところまで考えると、Node.jsでのテスト時にローカル関数をstubへのhookする方法 - Qiitaで紹介されているhook化は、感心してしまいます。
自分もこういう有効なやり方はどんどん取り入れていきたいし、自分でもそういうのをもっと思いついていかないとなあ、と思いました。
告知
3/20(土)に開催される「JAWS DAYS 2021」にて、「AWS Lambdaのいろいろなテスト方法」という内容で登壇させていただくことになりました。
jawsdays2021.jaws-ug.jp
内容としては、1/6(水)の「Serverless Meetup Japan Virtual #14」での登壇内容である「Severless Frameworkで行うLambdaテスト」をベースに、時間の関係でお話しできなかった内容を追加する予定です。
あと、上記「Serverless Meetup Japan Virtual #14」での登壇スライドですが、Youtubeの「新時代のサーバーレス チャンネル」チャンネルにて、AWSの西谷さんに紹介していただきました。(27:20くらいから)
Monthly AWS Serverless update 2021/01
それでは、今回はこの辺で。