お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。

本記事ではTypeScriptとSWC、Jestの組み合わせ環境でjest.spyOnを使う方法をexportパターン別に書いていきます。また、jest.spyOn対象の実装の詳細はテストせず、モック化したインターフェースのテストのみを行う前提で書いています。

確認環境

Env Ver
Node.js 18.14.0
typescript 4.9.5
@swc/cli 0.1.62
@swc/core 1.3.36
@swc/jest 0.2.24
jest 29.4.3

サンプルコード

全編は以下にあります
jest.spyOn pattern example for SWC + TypeScript

親関数から子関数を呼び出すパターンで、子関数のjest.spyOnをするシチュエーションで考えます。

default export編

以下のdefaultエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import child from 'src/default/child';

const parent = (param: string) => {
  return child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

この形式の場合import側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/default/child';
import { Parent } from 'src/default/parent';

jest.mock('src/default/child');

describe('default', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'default');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

named export編

以下の名前付きエクスポートをjest.spyOnする方法です。

child.ts
export const child = (param: string) => {
  return param;
};
parent.ts
import { child } from 'src/named-export/child';

export const parent = (param: string) => {
  return child(param);
};
parent.spec.ts

default exportと同じくimport側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import * as child from 'src/named-export/child';
import { parent } from 'src/named-export/parent';

jest.mock('src/named-export/child');

describe('function', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(child, 'child');
    parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

namespace export編

やり方はnamed export編と同じです。

child.ts
export namespace Child {
  export const child = (param: string) => {
    return param;
  };
}
parent.ts
import { Child } from 'src/namespace/child';

export namespace Parent {
  export const parent = (param: string) => {
    return Child.child(param);
  };
}
parent.spec.ts

namespaceの場合、トランスパイル後にクロージャになり、書き換え以前に関数にアクセスできなくなるため、ファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOnするのには向いていません。

import { Child } from 'src/namespace/child';
import { Parent } from 'src/namespace/parent';

jest.mock('src/namespace/child');

describe('namespace', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

module export編

これだけやり方が変わります。

child.ts
const child = (param: string) => {
  return param;
};

export const Child = {
  child,
};
parent.ts
import { Child } from 'src/module/child';

const parent = (param: string) => {
  return Child.child(param);
};

export const Parent = {
  parent,
};
parent.spec.ts

このケースの場合オブジェクトをexportしていて書き換えができるため、モック化せずにそのままjest.spyOnすることが出来ます。
このやり方であれば、ファイルのモックはしないため、同じファイルにある関数をjest.spyOnする事ができますが、裏を返せばオブジェクトの書き換えが可能であるため、実装方法によっては予期せぬ不具合が生まれる可能性があり、危険です。
何かしらの悪意ある攻撃を受けた場合にオブジェクトがすり替わるなどすると致命的だと思います。

import { Child } from 'src/module/child';
import { Parent } from 'src/module/parent';

describe('module', () => {
  it('called by child function', () => {
    const spiedChild = jest.spyOn(Child, 'child');
    Parent.parent('foo');

    expect(spiedChild).toHaveBeenCalledWith('foo');
  });
});

おまけ:それぞれのトランスパイル結果

.spec.jsは省いてます

default export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'default', {
  enumerable: true,
  get: () => _default,
});
const child = (param) => {
  return param;
};
const _default = child;
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = /*#__PURE__*/ _interopRequireDefault(require('./child'));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule
    ? obj
    : {
        default: obj,
      };
}
const parent = (param) => {
  return (0, _child.default)(param);
};
const Parent = {
  parent,
};

named export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'parent', {
  enumerable: true,
  get: () => parent,
});
const _child = require('./child');
const parent = (param) => {
  return (0, _child.child)(param);
};

namespace export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'child', {
  enumerable: true,
  get: () => child,
});
const child = (param) => {
  return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
var Parent;
(function (Parent) {
  var parent = (Parent.parent = (param) => {
    return _child.Child.child(param);
  });
})(Parent || (Parent = {}));

module export編

child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Child', {
  enumerable: true,
  get: () => Child,
});
const child = (param) => {
  return param;
};
const Child = {
  child,
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
  value: true,
});
Object.defineProperty(exports, 'Parent', {
  enumerable: true,
  get: () => Parent,
});
const _child = require('./child');
const parent = (param) => {
  return _child.Child.child(param);
};
const Parent = {
  parent,
};

この記事を書いた切っ掛け

一言でいうとTSC(TypeScript Compiler) からSWCに移行した際にjest.spyOnを使ったら、上手く使えずにハマったためです。

まずTypeScript + JestではTypeScriptをCJS(CommonJS) にトランスパイルするためjest.spyOnが有効でした。しかし、SWCはTypeScriptをESM(ES Modules) にトランスパイルします。ESMではjest.spyOnが有効になりません。

これはCJSではexportしたObjectの書き換えが可能なのに対し、ESMでは出来ないためです。(jest.spyOnは実行時にオブジェクトを書き換えることで動いています)

この対策としてファイルそのものをモックに置き換えることで、jestの管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)

条件分岐でコンポーネントの出し分けをしている時に正しくコンポーネントが出ているかどうかに使えるやつ
UIロジックのリグレッションテストで使える
分岐結果の出力を見てるだけなのでテストとして壊れづらく、運用しやすいと考えている

確認環境

Next.jsで確認してるけど素のReactでも同じだと思う

Env Ver
@swc/core 1.2.133
@swc/jest 0.2.17
jest 27.4.7
next 12.0.8
react 17.0.2
react-dom 17.0.2
react-test-renderer 17.0.2
typescript 4.5.4

テスト対象

テストのためにコンポーネントを細かくexportすると名前空間が汚染されるのが悩み…

type BaseProps = {
  id: string;
};

type SwitchExampleProps = BaseProps & {
  display: 'Foo' | 'Bar';
};

export const Foo = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Foo</p>
    </div>
  );
};

export const Bar = (props: BaseProps) => {
  return (
    <div id={props.id}>
      <p>Bar</p>
    </div>
  );
};

export const SwitchExample = (props: SwitchExampleProps) => {
  if (props.display === 'Foo') {
    return <Foo id={props.id} />;
  } else {
    return <Bar id={props.id} />;
  }
};

テストコード

react-testing-libraryの.toHaveAttribute().toHaveDisplayValue()を書き連ねるより圧倒的に楽で保守性も良いと思う

import TestRenderer from 'react-test-renderer';
import { Bar, Foo, SwitchExample } from './SwitchExample';

type TestCase = {
  name: string;
  param: Parameters<typeof SwitchExample>[0];
  actual: JSX.Element;
};

describe('SwitchExample', () => {
  const testCaseItems: TestCase[] = [
    {
      name: 'Foo',
      param: {
        id: 'hoge',
        display: 'Foo',
      },
      actual: <Foo id={'hoge'} />,
    },
    {
      name: 'Bar',
      param: {
        id: 'piyo',
        display: 'Bar',
      },
      actual: <Bar id={'piyo'} />,
    },
  ];

  testCaseItems.forEach((item) => {
    // eslint-disable-next-line jest/valid-title
    it(`switched condition ${item.name}`, () => {
      const result = TestRenderer.create(
        <SwitchExample id={item.param.id} display={item.param.display} />
      );
      const actual = TestRenderer.create(<>{item.actual}</>);

      expect(result.toJSON()).toStrictEqual(actual.toJSON());
    });
  });
});

参考記事

確認環境

Env Ver
next 11.1.2
typescript 4.3.4

サンプルコード

__mocks__/next/router.ts

export const useRouter = () => {
  return {
    route: '/',
    pathname: '',
    query: '',
    asPath: '',
    push: jest.fn(),
    replace: jest.fn(),
    events: {
      on: jest.fn(),
      off: jest.fn(),
    },
    beforePopState: jest.fn(() => null),
    prefetch: jest.fn(() => null),
  };
};
  • global.navigatorをモックにする方法
    • モックというか書き換えてるだけ
  • jestのモック機能はプロパティのモックが出来ないので、実オブジェクトを強制的に書き換えて実施する

サンプルコード

  • Object.defineProperty() を利用して実装
    • value, プロパティが返す値
    • configurable, 再定義可能かどうか、設定しないと再実行でコケる
  • 実際の使用ではユーティリティ関数を作っておき、 afterAll()navigator.userAgentを初期値に戻すのが望ましい
Object.defineProperty(global.navigator, 'userAgent', {
  value:
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.82 Safari/537.36',
  configurable: true,
});