お知らせ

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

ESM化が叫ばれて久しいですが、未だにJestはESMとCJSが混在したコードを処理してくれません。

Getting StartedにもSWCに対する言及がないので、きっともう忘れられているのでしょう。swc-project/jestの方も特にやる気はなさそうだし、やりたければ自分でPR書きましょうって感じだと思います。きっと。

確認環境

node_modules配下にESMで作られたモジュールが存在し、コードはTypeScript、トランスパイルにはSWCを利用する。

Env Ver
@swc/cli 0.1.62
@swc/core 1.3.92
@swc/jest 0.2.29
@swc/register 0.1.10
jest 29.7.0
typescript 5.2.2

やったけど意味がなかったこと

  • package.jsontypemoduleにする
  • jest.config.jstransformIgnorePatternsにESMモジュールのパスだけ除外する設定を書く
  • 上記に加えてtransform.jsc.pathpkg-name: ['node_modules/pkg-name']を追記する
  • node --experimental-vm-modules node_modules/jest/bin/jest.jsで実行する
    • 多少マシになったがコケるものはコケる
  • ESMで書かれたモジュールを丸ごとモックする
    • 一切効果なし

所感

多分Webpackでバンドルしてnode_modulesの中身も外も関係ない状態にするのが一番無難なのではないかと思いました。

Node.jsの組み込みテストランナーにすれば解決するかな?と思ったものの、こちらは現状SWCでは使えそうにないので諦めました。
参考までに以下のコマンドで走らさせられます。

node --require @swc/register --test ./src/**/*.spec.ts

取り敢えずESMに引っかったモジュールはCJS時のバージョンを維持しておくことにしましたが、このままだとSWC使えないし、なんとかなって欲しいですね。Webpack使えば解決できるのはわかるんですが、このために使いたくないので、テストを重視する場合、Vitestを持つViteが有力候補になって来そうです。

2024-02-17追記

esbuild + Node.js built-in test runnerの組み合わせであればテストはできるが肝心の実行ができず無意味だった

undefinedの判定方法が複数あるということでundefined判定の処理速度比較をしてみたのでその結果。

端的に言うと、hoge === undefinedtypeof hoge === 'undefined'の二方式がある。後者は原則考慮不要だが、言語仕様上存在しているので比較したが、現実的に見た場合、どちらで記述した場合でも処理速度に有意な差はないように感じた。

確認環境

Env Ver
Node.js 20.1.0
TypeScript 4.9.5
@swc/core 1.3.8

比較結果

hoge === undefinedの方が早く見えるが実行するタイミングで変わるので誤差の範疇だと思う。

方式 ms
hoge === undefined 4,514
typeof hoge === 'undefined' 4,515

確認コード

const tyof = (param?: string) => {
  return typeof param === 'undefined';
};

const undef = (param?: string) => {
  return param === undefined;
};

const tyStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  tyof();
}
console.log('typeof', +new Date() - tyStart);

const unStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  undef();
}
console.log('undefined', +new Date() - unStart);

TSから生成されたJS

"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
const tyof = (param)=>{
    return typeof param === 'undefined';
};
const undef = (param)=>{
    return param === undefined;
};
const tyStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    tyof();
}
console.log('typeof', +new Date() - tyStart);
const unStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    undef();
}
console.log('undefined', +new Date() - unStart);

あとがき

MDNを読む限りtypeof hoge === 'undefined'は該当変数が存在しない場合に有用なようであるが、TypeScriptで書いている場合、通常このようなコードが生まれることがなく、仮に起きるとした場合、次のようなコードになるため現実的に考慮する必要はない。なおMDNにも「こんなことはしないこと」と書いてあるので、一般的なコードでないことは客観的にも伺えるだろう。

(() => {
  const undefined = 123;
  const hoge = undefined;

  if (typeof hoge === 'undefined') {
    console.log('hoge is undefined');
  } else {
    console.log('hoge is not undefined');
  }
})();

上記コードの実行結果としてはhoge is not undefinedが出力される。

このコードの主な問題点

  1. const undefined = 123;というコードは予約語を変数名にしているため、混乱を招くコードであり、書かないことが好ましい
    1. MDNには予約語ではないとあるが、一般的には予約語の一つとして解釈して支障ないと考える
  2. このコードはESLintのeslint:recommendedで検知されるため、通常であれば書かれることはない
    1. no-shadow-restricted-namesに引っかかる

なお、このコードは例示のために即時実行関数形式で記述しているが、必要がない限りこの形式での実装は避けたほうが問題が少なくなると思う。これは不必要なネストが生まれたり、スコープの混乱を生むためである。

投稿日:
言語::JavaScriptジャンル::調査

関数の仮引数の書き方でオブジェクトの参照がどう変わるか気になったので試したメモ。別に仮引数でなくても分割代入なら何でも結果は同じになると思う。

これはプロジェクトのコーディング規約に定めがなく、無秩序な状態になっていると不具合を引き起こす因子になると思うので、どの方法を取るのか決めたほうが品質に寄与すると考える。個人的には引数由来であることが明示的であり、必ず参照を引きずるオブジェクト代入方式(後述)が好みだ。

結果

分割代入した結果プリミティブになると元オブジェクトとの参照が切れる

仮引数方式 参照
分割代入
スプレッド代入
オブジェクト代入

分割代入した結果、オブジェクトのままだと元オブジェクトとの参照が残る(Shallow copy)
再代入は参照が切れるので影響しない(再代入で参照アドレスが変わるためと思われる)

仮引数方式 参照
オブジェクト再代入
オブジェクト書き換え

確認コード

// 分割代入
const foo = ({ id, name }) => {
  id = 2;
  name = 'aaaa';
  console.log(id, name);
};

// スプレッド代入
const bar = ({ ...props }) => {
  props.id = 2;
  props.name = 'aaaa';
  console.log(props.id, props.name);
};

// オブジェクト代入
const baz = (props) => {
  props.id = 2;
  props.name = 'aaaa';
  console.log(props.id, props.name);
};

// オブジェクト再代入
const hoge = ({ id, name, dat }) => {
  id = 2;
  name = 'aaaa';
  dat = {
    value: 3,
  };
  console.log(id, name, dat);
};

// オブジェクト書き換え
const piyo = ({ id, name, dat }) => {
  id = 2;
  name = 'aaaa';
  dat.value = 3;
  console.log(id, name, dat);
};


const test1 = { id: 1, name: 'test' };
const test2 = { id: 1, name: 'test' };
const test3 = { id: 1, name: 'test' };
const test4 = { id: 1, name: 'test', dat: { value: 1 } };
const test5 = { id: 1, name: 'test', dat: { value: 1 } };


foo(test1);
console.log(test1);

bar(test2);
console.log(test2);

baz(test3);
console.log(test3);

hoge(test4);
console.log(test4);

piyo(test5);
console.log(test5);

結果

// 分割代入では呼び出し元が影響を受けていない
2 'aaaa'
{id: 1, name: 'test'}

// スプレッド代入では呼び出し元が影響を受けていない
2 'aaaa'
{id: 1, name: 'test'}

// オブジェクト代入では呼び出し元が影響を受ける
2 'aaaa'
{id: 2, name: 'aaaa'}

// オブジェクト再代入では呼び出し元が影響を受けていない
2 'aaaa' {value: 3}
{id: 1, name: 'test', dat: { value: 1}}

// オブジェクト書き換えでは呼び出し元が影響を受ける
2 'aaaa' {value: 3}
{id: 1, name: 'test', dat: { value: 3}}

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

投稿日:
言語::JavaScript言語::正規表現

ググっても意外と出てこないレア情報な気がする

replace()

'foo bar'.replace(/(?<first>.+?) (?<last>.+)/, '$<first>-$<last>');

match()

const mat = 'foo bar'.match(/(?<first>.+?) (?<last>.+)/);
console.log(mat.groups.first, mat.groups.last);

参考