echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

【Jest】SpyOnで関数のMock化ができない場合の対処

概要

JavaScriptのソースを単体テストする際、Jestを使っている人も多いと思います。

jestjs.io

で、ある関数のテストをする際に、その関数が呼ぶ別の関数を一時的に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になっています。) f:id:Makky12:20210205202844p:plain

こんな感じで、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が返ってます)
f:id:Makky12:20210206085930p:plain

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になっています。

このことからも、自分で書き換えた内容が正しく反映されているのがわかります。
f:id:Makky12:20210206194104p:plain

まとめ

以上、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

それでは、今回はこの辺で。