ch1hh1
@ch1hh1

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

TypeScript×Jestで、オブジェクト内のメソッドが関数を呼び出すプログラムのテストがうまくいかない

解決したいこと

typescriptのプログラムを、jestでテストしています。プログラム側ではオブジェクト内に定義されたメソッドが、さらに内部で関数を呼び出します。Jestでは、関数をモック化した上で、テスト内でメソッドを実行します。
テストの実行結果を見るに、①関数がモック化されていない、あるいは ②関数のモック化はできているが、テスト実行時にモック化した関数がよばれていない という状況です。関数を正しくモック化し、テスト実行時にもモック化した関数が動くようにしたいです。

バージョン

  • jest:29.7.0

発生している問題・エラー

あるコードがあります。(プロダクトコードと呼びます)
__private__というオブジェクトが定義され、内部でparentというメソッドが定義されています。
同じファイルでオブジェクト外にchildという関数が定義されています。parentメソッドは、実行時にchildを呼び出します。
そのコードはこちらです。

//(ファイル名:middleware.ts)
// オブジェクト外で定義される関数。引数を二乗する。
export const child = (num: number) => {
  console.log('pdコード');
  return num * num;
}

// exportされるオブジェクト。内部にメソッド定義を持つ。
export const __private__ = {

  // メソッドparentは、受けた引数をchildに渡す。childの返却値をそのまま返す。
  parent(pnum: number) {
    const ch = child(pnum);
    return ch;
  },
};

このmiddleware.tsをテストします。
テストにおいては、__private__内のparentを実行し、その中で呼び出されるchildが返却した値を、常にparentも返却することをテストします。すなわち、childの内部実装がなんであれ、parentchildの返却値を脚色・捏造しないことを確かめます。
具体的には以下の方法です。

  1. childをモック化し、常に100を返す様にする。
  2. parentを呼び出し、引数に3を与える(この引数はnumberならなんでもよい)
  3. childの返却値をparentはそのまま返すはずなので、返却値100を期待する。
    作成したテストコードは以下です。
//(ファイル名:middleware.test.ts)
import { __private__ } from '../backend/middleware'

// childのみをモック化
jest.mock("../backend/middleware", () => {
  const origin = jest.requireActual("../backend/middleware");

  // childはテスト内で常に100を返すようにモック化する
  return {
    __esmodule: true,
    ...origin,
    child: jest.fn().mockReturnValue(100),
  }
});

describe('middleware.tsに対するテスト', () => {
  test('childが返却した値を、常にparentも返却すること', () => {

    const answer = __private__.parent(3);

    // childが返した値を脚色せずparentも返す必要がある。それをテストする。
    expect(answer).toBe(100);
  });
})

test「childが返却した値を、常にparentも返却すること」を実行すると、テストは「不合格」になります。
テスト結果です。

● middleware.tsに対するテスト › childが返却した値を、常にparentも返却すること

    expect(received).toBe(expected) // Object.is equality

    Expected: 100
    Received: 9

      20 |
      21 |     // childが返した値を脚色せずparentも返す必要がある。それをテストする。
    > 22 |     expect(answer).toBe(100);
         |                    ^
      23 |   });
      24 | })

モック化した返却値の100ではなく、実際に返却されたのは9です。

自分で試したこと

実際のchild側にconsole.log('実際のchild')を仕込み、テスト実行時に動くchildはプロダクトコードのものか、モック化したものか判別しました。テスト実行時に、「実際のchild」が出力されましたので、間違いなくプロダクトコード側が呼ばれています。

結果から、推測している原因は2つです。(あくまで推測です)

  • childのモック化の記述に誤りがあるため、正しくモックになっていない。そのため、実際のchildが呼び出されてしまった
  • childのモック化はできているが、別の要因でテストの時は実際のchildが呼び出された

どのような方法なら意図したテストが実施できるか、どうかお力をお貸しいただきたいです。

補足

本件のプロダクトコードは、実際になにかのアプリに組み込むものではありません。ある開発で同ケースの問題があったため、質問用に模倣したものだとお考えください。問題解決のために、プロダクトコード側をリファクタリングするという解決は採用しにくいことをご理解いただけますと幸いです。

0

3Answer

試しに次のようなテストをすると、テストは通ったのでchildはモックされているようです。

- import { __private__ } from '../backend/middleware'
+ import { __private__, child } from '../backend/middleware'

// ...

+   test('childがモックされている', () => {
+     expect(child(3)).toBe(100);
+   });

また、childがさらに別のモジュールとして存在する場合では、parentの中もモックされるようになりました。

- export const child = (num: number) => {
-   console.log('pdコード');
-   return num * num;
- }
+ import { child } from './hoge.ts'

どうやら現状では意図しているようにモックすることはできないのかもしれません。

ちなみに実際のchildはランダム性を持っていたり、テストで実行できない類のものでしょうか?記載のコードだけを見るとモックをする必要は感じられず、次のようなテストで「childが返却した値を、常にparentも返却すること」がテストされているのではないかと思います。

test('childが返却した値を、常にparentも返却すること 1', () => {
  expect(__private__.parent(3)).toBe(9);
});
test('childが返却した値を、常にparentも返却すること 2', () => {
  expect(__private__.parent(3)).toBe(child(3));
});
1Like

Comments

  1. @ch1hh1

    Questioner

    @blue32a 様、ご回答ありがとうございます。

    • childのモック化自体はできていること
    • childを別モジュール("./hoge.ts")に移動し、middleware.tsでimportしている状態ならば、テストでparentを実行した際にモック化されたchildが動くこと

    この二点を私の環境でも同様に確認できました。

    想像ですが、parentがオブジェクト内のメソッドかつ、同じファイル内に定義される関数を内部で呼び出すという状態が、Jestの想定外なのかもしれないと思いました。おっしゃる様に、現在のコードでは意図したモック化・意図したテストができないと考えられます。"プロダクトコード側をリファクタリングするという解決は採用しにくい"などと書きましたが、どうにもそれ以外に解決方法がないと理解しました。

    ちなみに実際のchildはランダム性を持っていたり、テストで実行できない類のものでしょうか?

    この問題のきっかけになった実際のコードでは、childに相当する位置に「外部のAPIを叩くための関数」があります。ユーザーのリクエストを受けAPIを叩きますので、テストにおいては実行できないものになります。(ここを事前にお伝えすべきでした)


    ここまでの結果より、「オブジェクトの"メソッド"が、同ファイル内の"関数"を呼び出す場合、Jestにおいてモック化した"関数"が"メソッド"実行時に動かず、本物の"関数"が呼ばれてしまう」という教訓を得ました。開発時のアンチパターンとして覚えておこうと思います。

    検証・調査のご協力及びご提案をいただき、誠にありがとうございます。

解決しているようであれば良いのですが、気になったので。

jest.spyOn()を利用したテストコードはいかがでしょうか?
https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname

import * as middleware from '@/middleware'; // インポート文をモジュール全体で行う

describe('middleware.tsに対するテスト', () => {
  // childのモック化
  const spyOnChild = jest.spyOn(middleware, 'child');
  spyOnChild.mockReturnValue(100);
  
  test('childが返却した値を、常にparentも返却すること', () => {
    const answer = middleware.__private__.parent(3);
    expect(answer).toBe(100);
    // parentが呼ばれた際にchildが呼ばれていることを確認
    expect(spyOnChild).toHaveBeenCalled();
  });
});

ご参考までに。

1Like

Comments

  1. @ch1hh1

    Questioner

    @rokumura7
    ご回答いただき、ありがとうございます。
    何と言いますか……
    あっさりと解決いたしました。
    画像ではdescribe外でspyOnしていますが、ご提供いただいたdescribe内のパターンでも同様の結果でした。
    スクリーンショット 2024-01-05 22.12.44.png

    実はspyOnも試していたはずなのですが、どうにもうまくいかず検討対象から外しておりました。まったくもって基本通りの方法でよかったところを、変にこねくり回してしまったのかもしれません。

    ご提案いただき、ありがとうございます!

自分でspyOnを試した時うまくいかなかった原因

  • spyOnではモック対象がオブジェクトの一員でないといけないと思い、変なオブジェクトを1枚噛ませていた。
import * as middleware from '../backend/middleware'

/* spyOnではモック対象がオブジェクトの一員でないといけないと思い、
変なオブジェクトを1枚噛ませていた。
*/
const middleObj = {
  child:middleware.child
}
const spyChild = jest.spyOn(middleObj,'child');
spyChild.mockReturnValue(200);


describe('middleware.tsに対するテスト', () => {
  test('childが返却した値を、常にparentも返却すること', () => {

    const answer = middleware.__private__.parent(3);
    // childが返した値を脚色せずparentも返す必要がある。それをテストする。
    console.log(answer);
    expect(answer).toBe(200);
  });
})

これでテストすると失敗します。実際のchildが呼ばれてしまいます

'middleware\.tsに対するテスト childが返却した値を、常にparentも返却すること'
  console.log
    実際のchild

      at child (src/backend/middleware.ts:3:11)

  console.log
    9

      at Object.<anonymous> (src/__tests__/middleware.test.ts:18:13)

 FAIL  src/__tests__/middleware.test.ts
  middleware.tsに対するテスト
    ✕ childが返却した値を、常にparentも返却すること (109 ms)

  ● middleware.tsに対するテスト › childが返却した値を、常にparentも返却すること

    expect(received).toBe(expected) // Object.is equality

    Expected: 200
    Received: 9

      17 |     // childが返した値を脚色せずparentも返す必要がある。それをテストする。
      18 |     console.log(answer);
    > 19 |     expect(answer).toBe(200);
         |                    ^
      20 |   });
      21 | })

      at Object.<anonymous> (src/__tests__/middleware.test.ts:19:20)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        4.333 s

モジュールの中身全体を、import * as名前空間オブジェクトとしてインポートする形式ならば、わざわざ変なオブジェクトを挟む必要はありませんでした。

0Like

Your answer might help someone💌