jest.spyOn を呼び出すと TypeError: Cannot redefine property が発生する
はじめに
Microsoft Graph JavaScript Client Library (@microsoft/microsoft-graph-client) を 3.0.0 に上げたとき、テストが失敗するようになりました。BatchResponseContent のモックが原因でしたが、BatchResponseContent のコードには何も変更がありませんでした。原因不明のため issue を上げました。
結論としては 3.0.0 から TypeScript 4 でビルドするようになったため、その影響です とのことでした。納得はしたものの、現象を理解するために少し調査を進めました。
サンプルコード
実行手順
中身は同じコードで TypeScript のバージョンだけが異なる 2 つのプロジェクトを用意します。
src/HelloWorld.ts
export class HelloWorld {
output() {
console.log('Hello World');
}
}
src/index.ts
export * from './HelloWorld';
tsconfig.json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"declaration": true,
"outDir": "dist"
},
"include": [
"src"
]
}
テストコード
上記で作成したプロジェクトを参照し、jest.spyOn でモックします。
import * as typescript2 from 'typescript2';
import * as typescript4 from 'typescript4';
it('jest.spyOn() with typescript 2', () => {
jest.spyOn(typescript2, 'HelloWorld');
});
it('jest.spyOn() with typescript 4', () => {
jest.spyOn(typescript4, 'HelloWorld');
});
これを実行すると、以下の結果になります。TypeScript 2 ではテストが成功しますが、TypeScript 4 ではテストが失敗します。
FAIL src/index.test.ts
√ jest.spyOn() with typescript 2 (1 ms)
× jest.spyOn() with typescript 4 (1 ms)
● jest.spyOn() with typescript 4
TypeError: Cannot redefine property: HelloWorld
at Function.defineProperty (<anonymous>)
7 |
8 | it('jest.spyOn() with typescript 4', () => {
> 9 | jest.spyOn(typescript4, 'HelloWorld');
| ^
10 | });
11 |
at ModuleMocker.spyOn (node_modules/jest-mock/build/index.js:831:16)
at Object.<anonymous> (src/index.test.ts:9:8)
ビルドされたコード
dist/index.js
TypeScript 2.9.2 でビルドしたコードは以下の通りです。
"use strict";
function __export(m) {
for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
}
Object.defineProperty(exports, "__esModule", { value: true });
__export(require("./HelloWorld"));
TypeScript 4.4.3 でビルドしたコードは以下の通りです。
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./HelloWorld"), exports);
明らかに異なる点は、__exportStar から各プロパティに __createBinding を呼び出しており、最終的に Object.defineProperty が呼ばれることです。ここで既定値の configurable: false が指定され、読み取り専用としてマークされることが jest.spyOn でエラーが発生する原因です。
おわりに
ではどうすればよいかについて、いくつかのオプションを紹介します。
インポート先のファイルを index.js から個々のファイルに変更
たとえば先のサンプルの場合、以下のようにすることで回避が可能です。__exportStar が呼ばれないため jest.spyOn が利用できます。
import * as typescript2 from 'typescript2';
- import * as typescript4 from 'typescript4';
+ import * as typescript4 from 'typescript4/dist/HelloWorld';
it('jest.spyOn() with typescript 2', () => {
jest.spyOn(typescript2, 'HelloWorld');
});
it('jest.spyOn() with typescript 4', () => {
jest.spyOn(typescript4, 'HelloWorld');
});
ただし、ライブラリが webpack でバンドルされている場合はこの方法を利用できません。
ライブラリ側で export * をやめる
export するプロパティを明示的に指定すると __exportStar が生成されず、回避できます。
- export * from './HelloWorld';
+ export { HelloWorld } from './HelloWorld';
ただし、ライブラリ自体を変更する必要があるため、対応は限定的です。
jest.spyOn をあきらめる
jest.mock を利用する方法もあります。
Discussion