お知らせ

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

Node.jsとGoogle Chrome, Microsoft Edgeを用いて、try-catchとif文でエラー処理にかかる時間がどのくらい違うのかを調べた。

計測手法

次の4パターンを100万回実行した結果を記載している。

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  3. エラーオブジェクトをthrowし、try-catchで判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

確認に使用したコード:https://gist.github.com/Lycolia/304bc9e825e821c2d582f3ef9f700817

計測結果

CPUによって処理速度がかなり変動するが、いずれの環境でも処理速度の速さは以下の通り。

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. エラーオブジェクトをthrowし、try-catchで判定する方式
  3. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

計測に使用したCPU

CPU コア スレッド クロック L2キャッシュ L3キャッシュ
Intel Core i7 13700 16 24 5.20GHz 24.00MB 30.00MB
Intel Core i7 1265U 10 12 4.80GHz 6.50MB 12.00MB
AMD Ryzen 5 5600G 6 12 3.90GHz 3.00MB 16.00MB

Intel Core i7 13700端末

16C24T, 5.20GHz, L2 24.00MB, L3 30.00MB。ミドルタワーPC。

Node.js v16.20.2

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 802
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,942
エラーオブジェクトをthrowし、try-catchで判定する方式 1,847
エラーインスタンスをthrowし、try-catchで判定する方式 6,527
Node.js v18.19.1

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 887
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,826
エラーオブジェクトをthrowし、try-catchで判定する方式 2,006
エラーインスタンスをthrowし、try-catchで判定する方式 6,464
Node.js v20.11.1

処理環境

Env Ver
CPU Intel Core i7 13700
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 720
Result型としてエラーインスタンスをreturnし、if文で判定する方式 5,375
エラーオブジェクトをthrowし、try-catchで判定する方式 1,690
エラーインスタンスをthrowし、try-catchで判定する方式 5,978
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU Intel Core i7 13700
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,246
Result型としてエラーインスタンスをreturnし、if文で判定する方式 12,329
エラーオブジェクトをthrowし、try-catchで判定する方式 11,985
エラーインスタンスをthrowし、try-catchで判定する方式 14,105
Google Chrome 122.0.6261.58

処理環境

Env Ver
CPU Intel Core i7 13700
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 3,507
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,793
エラーオブジェクトをthrowし、try-catchで判定する方式 10,482
エラーインスタンスをthrowし、try-catchで判定する方式 12,452

Intel Core i7 1265U端末

10C12T, 4.80GHz, L2 6.50MB, L3 12.00MB。ノートPC。

Node.js v16.20.2

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 972
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,416
エラーオブジェクトをthrowし、try-catchで判定する方式 2,288
エラーインスタンスをthrowし、try-catchで判定する方式 13,993
Node.js v18.19.1

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,102
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,333
エラーオブジェクトをthrowし、try-catchで判定する方式 2,441
エラーインスタンスをthrowし、try-catchで判定する方式 12,827
Node.js v20.11.1

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Ubuntu 20.04.6 LTS (WSL2)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 888
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,881
エラーオブジェクトをthrowし、try-catchで判定する方式 2,269
エラーインスタンスをthrowし、try-catchで判定する方式 12,254
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 7,526
Result型としてエラーインスタンスをreturnし、if文で判定する方式 34,761
エラーオブジェクトをthrowし、try-catchで判定する方式 39,005
エラーインスタンスをthrowし、try-catchで判定する方式 65,752
Google Chrome 122.0.6261.58

処理環境

Env Ver
CPU Intel Core i7 1265U
OS Windows 11 Pro 22H2(22621.3155)

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 6,303
Result型としてエラーインスタンスをreturnし、if文で判定する方式 16,460
エラーオブジェクトをthrowし、try-catchで判定する方式 17,979
エラーインスタンスをthrowし、try-catchで判定する方式 23,378

AMD Ryzen 5 5600G端末

6C12T, 3.90GHz, L2 3.00MB, L3 16.00MB。ミニタワーPC。

この端末はOSがUbuntuであるため、これまでのWindows環境との単純比較はできない。

Node.js v16.20.2

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,786
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,242
エラーオブジェクトをthrowし、try-catchで判定する方式 3,880
エラーインスタンスをthrowし、try-catchで判定する方式 12,716
Node.js v18.19.1

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,933
Result型としてエラーインスタンスをreturnし、if文で判定する方式 11,094
エラーオブジェクトをthrowし、try-catchで判定する方式 3,839
エラーインスタンスをthrowし、try-catchで判定する方式 12,767
Node.js v20.11.1

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 1,714
Result型としてエラーインスタンスをreturnし、if文で判定する方式 10,741
エラーオブジェクトをthrowし、try-catchで判定する方式 3,444
エラーインスタンスをthrowし、try-catchで判定する方式 11,978
Microsoft Edge 121.0.2277.128

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,348
Result型としてエラーインスタンスをreturnし、if文で判定する方式 21,584
エラーオブジェクトをthrowし、try-catchで判定する方式 15,543
エラーインスタンスをthrowし、try-catchで判定する方式 23,119
Google Chrome 122.0.6261.57

これだけバージョンが合わなかった。

処理環境

Env Ver
CPU AMD Ryzen 5 5600G
OS Ubuntu 22.04.3 LTS

処理結果

処理方式 処理時間(ms)
Result型としてエラーオブジェクトをreturnし、if文で判定する方式 4,842
Result型としてエラーインスタンスをreturnし、if文で判定する方式 16,607
エラーオブジェクトをthrowし、try-catchで判定する方式 15,051
エラーインスタンスをthrowし、try-catchで判定する方式 18,095

全体サマリ表

凡例

  1. Result型としてエラーオブジェクトをreturnし、if文で判定する方式
  2. Result型としてエラーインスタンスをreturnし、if文で判定する方式
  3. エラーオブジェクトをthrowし、try-catchで判定する方式
  4. エラーインスタンスをthrowし、try-catchで判定する方式

サマリ画像

CPUによってかなり差が出ているが、Intel CPUの13700と1265Uを比較した場合、例外を作らない1, 3のケースでは処理に大きな差がないが、例外を作る2, 4のケースでは有意な差が認められるので、モバイル向けとデスクトップ向けでCPUの処理効率に差があることがわかる。

AMD CPUと比較した場合、Node.js環境で例外を作らないケースではIntel CPUに大きく引けを取るが、ブラウザ上でのそれではパフォーマンスは高い。また例外を作るケースでもNode.js・ブラウザ共にIntel CPUと比較した場合のパフォーマンスが比較的よいという、面白い結果になっている。但しOSが違うことの影響もあるため、単純比較はできないが…。

あとがき

ひとまずtry-catchを使うと遅くなるということが確かめられたので良かったし、環境によって劇的遅くなるケースがあるというのは思わない収穫だった。

これを試そうと思った切欠はtry-catchが乱用されているコードを見てパフォーマンス劣化に繋がっているのではないかという疑問を抱いたからだ。try-catchでパフォーマンスの劣化が起きることは知識としても経験としても知っていたのだが、ちゃんとレポートしたことがなかったので今回まとめてみた。

try-catchの利用によりパフォーマンスの劣化が起きるというのは、古い時代にあった開発のお作法を知っている人であれば、それなりに常識だとは思うが、最近は知られていないか、知られていても意識していないことが少なくないと思われるので、今回記事にしてみた感じだ。

100万回の実行なので細かい話になるとは思うがパフォーマンスチューニングは細かいことの積み重ねでもあるので、むやみやたらに例外を使わないことは重要だと思う。そもそも例外は制御しづらいので、可能な限りif文で処理を書くことが望ましいだろう。

処理が遅延する原因としては二つあると考えていて、一つは例外生成時に顕著であることからスタックトレースの生成を初めとしたエラーオブジェクトの生成に時間がかかっているのと、もう一つはcatchでも遅延が出ることから、コールスタックから最寄りのcatchを辿るのに時間を取られていると思われる。今回の検証では特に出してないが、以前確認した時の記憶が確かであれば、catchに入らない限り、tryだけで顕著に処理が遅延することはなかったと思う。

あと、どうでもいいことだがconsole.log()で結果を出したときにEdgeだけObjectのKeyの順序が他と違ったので転記しているときに微妙に不便だった。何回やっても同じだったので恐らくEdgeだけキーをソートするアルゴリズムが違うのだと思う。まぁObjectとかHashとかMapとかDictionaryみたいなやつは順序が保証されないので別にいいっちゃいいんだけど、まさか実装によってソート方式が違うというのは思いもしなかった。

EdgeとChromeで処理結果が優位に違うのも、恐らくこれが原因なのだろう。分からないが多分JSのエンジンの実装が違う気がする。

それとLinuxのEdgeにはマウスジェスチャー機能がないという悲しいことも分かった。

参考

JestからNode.js組み込みのテストランナーに移行する時の方法や注意点をまとめたメモ

確認環境

Env ver
Node.js 20.8.0
Jest 29.7

Node.jsの組み込みテストランナーとは

英語圏ではNode.js built-in test runnerとか呼ばれている存在で、Node.jsの組み込みテストランナーのこと。恐らくDenoのテストランナーに対抗して生まれた気配がする(Node.jsはDenoに機能追加があると真似する傾向があるため

Node.jsの組み込みテストランナーは機能が二分されており、テストランナーアサートに分かれている

Jestみたいにエコシステムが発達しておらず、標準ではdescribeなどはimportする必要がある。TypeScriptをテストする場合は間にレジスター[^1]を嚙ます必要があるが、本項では扱わない

Jest関数との対応リスト

describe, it, beforeAll, afterAll, beforeEach, afterEach辺りは違和感がなかったが、それ以外は軒並み互換性がないので大きく書き換えが必要だと感じた。便利なMatcherは完膚なきまでに全滅している。お陰で覚えることが減ったのは逆に良くなったと感じる

なお、not始まりの機能は実際の動作を確認しておらず、Jestと同じ機能かどうかは確認していないことに留意すること

describe(name, fn), it(name, fn, timeout?), test(name, fn, timeout?)

Node.jsのTest機能では、以下となる

  • describe([name][, options][, fn])
  • it([name][, options][, fn])
  • test([name][, options][, fn])

基本的な差はないがJestにあったtimeout引数はNode.jsではoptionsパラメーターで設定するようになっている模様。今回調べるまで存在自体を知らなかったのもあり、このtimeoutがJestと同じ機能かどうかは確認していない

import { describe, it } from 'node:test';

describe('hoge', () => {
  it('hoge', () => {
    // ここにテストコード
  });

  it.todo('piyo');
});

beforeAll(fn, timeout?), afterAll(fn, timeout?), beforeEach(fn, timeout?), afterEach(fn, timeout?)

Node.jsのTest機能では、以下となる

  • before([fn][, options])
  • after([fn][, options])
  • beforeEach([fn][, options])
  • afterEach([fn][, options])

基本的な差はないがJestにあったtimeout引数はNode.jsではoptionsパラメーターで設定するようになっている模様。今回調べるまで存在自体を知らなかったのもあり、このtimeoutがJestと同じ機能かどうかは確認していない

+import { after, before, beforeEach, afterEach, describe } from 'node:test';
+
 describe('test', () => {
-  beforeAll(() => {
+  before(() => {
     console.log('before');
   });
   beforeEach(() => {
     console.log('beforeEach');
   });
   afterEach(() => {
     console.log('afterEach');
   });
-  afterAll(() => {
+  after(() => {
     console.log('after');
   });
 });

.toBe(value), .not.toBe(value)

Node.jsのTest機能では、以下となる

  • assert.strictEqual(actual, expected[, message])
  • assert.notStrictEqual(actual, expected[, message])
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
     const actual = 123;

-    expect(actual).toBe(123);
+    assert.deepStrictEqual(actual, 123);
   });
 });

.toStrictEqual(value), .not.toStrictEqual(value)

Node.jsのTest機能では、以下となる

  • assert.deepStrictEqual(actual, expected[, message])
  • assert.notDeepStrictEqual(actual, expected[, message])
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
     const actual = {
       hoge: {
         piyo: {
           id: 1,
           value: 'one'
         }
       },
       fuga: [[123, 456], 'ABC', 'EFG']
     };

-    expect(actual).toStrictEqual({
+    assert.deepStrictEqual(actual, {
       hoge: {
         piyo: {
           id: 1,
           value: 'one'
         }
       },
       fuga: [[123, 456], 'ABC', 'EFG']
     });
   });
 });

.toHaveLength(number), .toBeNull(), .toBeUndefined(), .toBeNaN()

Node.jsのTest機能では、assert.strictEqual()となる

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
     const arr = [1, 2, 3];
     const undef = undefined;
     const nil = null;
     const nan = NaN;

-    expect(arr).toHaveLength(3);
-    expect(undef).toBeUndefined();
-    expect(nil).toBeNull();
-    expect(nan).toBeNaN();
+    assert.strictEqual(arr.length, 3);
+    assert.strictEqual(undef, undefined);
+    assert.strictEqual(nil, null);
+    assert.strictEqual(nan, NaN);
   });
 });

.toBeTruthy()

Node.jsのTest機能では、以下となる

  • assert.ok(value[, message])

否定版は不明

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
-    expect(true).toBeTruthy();
-    expect({}).toBeTruthy();
-    expect([]).toBeTruthy();
-    expect(42).toBeTruthy();
-    expect('0').toBeTruthy();
-    expect('false').toBeTruthy();
-    expect(new Date()).toBeTruthy();
-    expect(-42).toBeTruthy();
-    expect(12n).toBeTruthy();
-    expect(3.14).toBeTruthy();
-    expect(Infinity).toBeTruthy();
-    expect(-Infinity).toBeTruthy();
+    assert.ok(true);
+    assert.ok({});
+    assert.ok([]);
+    assert.ok(42);
+    assert.ok('0');
+    assert.ok('false');
+    assert.ok(new Date());
+    assert.ok(-42);
+    assert.ok(12n);
+    assert.ok(3.14);
+    assert.ok(Infinity);
+    assert.ok(-Infinity);
   });
 });

.toBeInstanceOf(Class)

Node.jsのTest機能では、assert.ok()となる

結果がTruthyであればなんでもpassするので注意

+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
     const actual = new Error('hoge');

-    expect(actual).toBeInstanceOf(Error);
+    assert.ok(actual instanceof Error);
   });
 });

.toThrow, not.toThrow

Node.jsのTest機能では、以下となる

  • assert.throws(fn[, error][, message])
  • assert.doesNotThrow(fn[, error][, message])

Jestより便利になっており、Error型以外も扱えるので後述する

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
     const err = new Error('test');

-    expect(() => {
+    assert.throws(() => {
       throw err;
-    }).toThrow(err);
+    }, err);
   });
 });
Node.jsではJestと異なりError型以外も扱える

Jestの`.toThrow()はError型以外を扱うことができず、オブジェクトをThrowするようなコードではエラーになる

describe('test', () => {
  it('test', () => {
    const obj = { id: 1, value: 'hoge' };

    // このテストは失敗する。またTypeScriptの型エラーにもなる
    expect(() => {
      throw obj;
    }).toThrow(obj);
    // このテストは失敗する。またTypeScriptの型エラーにもなる
    expect(() => {
      throw obj;
    }).toThrow({ id: 1, value: 'hoge' });
  });
});

しかしNode.jsであればこれはエラーにならない。例えば次のテストコードは成功する

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';

describe('test', () => {
  it('test', () => {
    const obj = { id: 1, value: 'hoge' };

    assert.throws(() => {
      throw obj;
    }, obj);
    assert.throws(
      () => {
        throw obj;
      },
      { id: 1, value: 'hoge' }
    );
  });
});

expect.arrayContaining(array), expect.objectContaining(object), expect.stringContaining(string)

恐らく非対称マッチャーはないので自分でロジックを書いてassert.strictEqual()で判定するしかないと思われる。元々微妙な機能だったのでやむなし

jest.spyOn(object, methodName, accessType?)

Node.jsのTest機能では、node:testからmockをimportして以下を使う

  • mock.method(object, methodName[, implementation][, options])
+import { describe, it, mock } from 'node:test';
+
 describe('test', () => {
   it('test', () => {
-    jest.spyOn(console, 'log');
+    mock.method(console, 'log');
   });
 });
本来の挙動を塞ぎたい場合

例えば以下のようにテストコードを書いた場合、execSync()が実際の挙動で動作してしまい、テストとして機能しない。

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';

const mockedExecSync = mock.method(child_process, 'execSync');

describe('execSync', () => {
  it('execSyncが正しい引数で呼ばれること', () => {
    execSync('false');

    assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
  });
});

このような場合、以下のようにmock.method()の第三引数を指定してやると封じることができる。単体テストの観点では基本的に第三引数には空関数を入れておくのが望ましいだろう。

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';

- const mockedExecSync = mock.method(child_process, 'execSync');
+ const mockedExecSync = mock.method(child_process, 'execSync', () => {});

describe('execSync', () => {
  it('execSyncが正しい引数で呼ばれること', () => {
    execSync('false');

    assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
  });
});
モックパターン
module.exportsされている関数のモック

import foo from 'foo';形式でmock.method()の第一引数を埋める

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';

const mockedExecSync = mock.method(child_process, 'execSync', () => {});

describe('execSync', () => {
  it('execSyncが正しい引数で呼ばれること', () => {
    execSync('false');

    assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
  });
});

上記が正常に動作することはNode.js v20.0.0時点のコードで確認している

Global objectから生えている関数のモック

以下のようにmock.method()の第一引数にGlobal objectを設定すればよい

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';

describe('exit', () => {
  // exitが実際に走って落ちるのでmock.methodの第三引数を指定している
  const mockedExit = mock.method(process, 'exit', () => {});

  it('call exit', () => {
    process.exit(1);

    assert.strictEqual(mockedExit.mock.calls.length, 1);
  });
});
Named exportされている関数のモックは今のところ無理そう

ファイルモックをする手段がないので正攻法では無理そう
https://github.com/nodejs/help/issues/4298

一応力技でやる方法はググれば出てくるが…

ObjectやNamespaceでラップされている関数のモック

実装例(Object)

export const Hoge = {
  validateFoo() {
    // 例外を飛ばす可能性のある何かの処理
  },
  hoge() {
    Hoge.validateFoo();

    return 1;
  },
};

実装例(Namespace)

export namespace Hoge {
  export const validateFoo = () => {
    // 例外を飛ばす可能性のある何かの処理
  };
  export const hoge = () => {
    validateFoo();

    return 1;
  };
}

実装例に対するテストコード

ObjectもNamespaceも同じ書き方でテスト可能

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Hoge } from './hoge';

describe('hoge', () => {
  it('validateFooが例外をスローした場合、例外がスローされること', (t) => {
    t.mock.method(Hoge, 'validateFoo', () => {
      throw new Error('foo');
    });

    assert.throws(() => {
      Hoge.hoge();
    }, Error('foo'));
  });

  it('全ての関数が正常終了した場合、戻り値を返すこと', () => {
    const actual = Hoge.hoge();

    assert.strictEqual(actual, 1);
  });
});

.toHaveBeenCalled(), .toHaveBeenCalledTimes(number)

Node.jsのTest機能では、assert.strictEqual()でモックから生えてるやつを調べる。returnも同様の手法で実現できる

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
-    const spiedConsoleLog = jest.spyOn(console, 'log');
+    const mockedConsoleLog = mock.method(console, 'log');

     console.log();

-    expect(spiedConsoleLog).toHaveBeenCalled();
-    expect(spiedConsoleLog).toHaveBeenCalledTimes(1);
+    assert.deepStrictEqual(mockedConsoleLog.mock.calls.length, 1);
   });
 });

.toHaveBeenCalledWith(arg1, arg2, ...)

Node.jsのTest機能では、assert.strictEqual()でモックから生えてるやつを調べる。returnも同様の手法で実現できる。

Jestでは.toEqual()処理されるがNode.jsの組み込みテストランナーの場合、厳密比較ができるので便利

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
-    const spiedConsoleLog = jest.spyOn(console, 'log');
+    const mockedInfo = mock.method(console, 'log');

     console.log('test');

-    expect(spiedConsoleLog).toHaveBeenCalledWith('test');
+    assert.deepStrictEqual(mockedInfo.mock.calls[0].arguments[0], 'test');
   });
 });

jest.fn(implementation?)

Node.jsのTest機能では、以下となる

  • mock.fn([original[, implementation]][, options])
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
+// 動作確認用の関数
 const testTarget = (cbParam: string, callbackFn: (param: string) => number) => {
   return callbackFn(cbParam);
 };

 describe('test', () => {
   it('test', () => {
-    const mockFn = jest.fn((_: string) => {
+    const mockFn = mock.fn((_: string) => {
       return 5;
     });
     const actual = testTarget('hoge', mockFn);

-    expect(mockFn).toBeCalledWith('hoge');
-    expect(mockFn).toReturnWith(5);
-    expect(actual).toBe(5);
+    assert.deepStrictEqual(mockFn.mock.calls[0].arguments[0], 'hoge');
+    assert.deepStrictEqual(mockFn.mock.calls[0].result, 5);
+    assert.deepStrictEqual(actual, 5);
   });
 });

jest.useFakeTimers(fakeTimersConfig?), jest.runAllTimers()

Node.jsのTest機能では、以下となる

  • mock.timers.enable([timers])
  • mock.timers.runAll()
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
+describe('test', () => {
-  jest.useFakeTimers();
+  mock.timers.enable();

+  it('test', () => {
-    const mockFn = jest.fn();
+    const mockFn = mock.fn();
     setTimeout(() => {
       mockFn();
     }, 9999);

-    expect(mockFn).not.toHaveBeenCalled();
-    jest.runAllTimers();
-    expect(mockFn).toHaveBeenCalledTimes(1);
+    assert.deepStrictEqual(mockFn.mock.calls.length, 0);
+    mock.timers.runAll();
+    assert.deepStrictEqual(mockFn.mock.calls.length, 1);
   });
 });

jest.useRealTimers()

Node.jsのTest機能では、以下となる

  • mock.timers.reset()
+import { mock } from 'node:test';

-jest.useRealTimers();
+mock.timers.reset();

mockFn.mockClear()

Node.jsのTest機能では、以下となる

  • ctx.resetCalls()
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
-    const mockFn = jest.fn((param: string) => `<${param}>`);
+    const mockFn = mock.fn((param: string) => `<${param}>`);

     mockFn('hoge');
-    expect(mockFn).toHaveBeenCalledTimes(1);
-    expect(mockFn).toReturnWith('<hoge>');
+    assert.deepStrictEqual(mockFn.mock.calls.length, 1);
+    assert.deepStrictEqual(mockFn.mock.calls[0].result, '<hoge>');

-    mockFn.mockClear();
+    mockFn.mock.resetCalls();

-    expect(mockFn).toHaveBeenCalledTimes(0);
+    assert.deepStrictEqual(mockFn.mock.calls.length, 0);
     mockFn('piyo');
-    expect(mockFn).toHaveBeenCalledTimes(1);
-    expect(mockFn).toReturnWith('<piyo>');
+    assert.deepStrictEqual(mockFn.mock.calls.length, 1);
+    assert.deepStrictEqual(mockFn.mock.calls[0].result, '<piyo>');
   });
 });

mockFn.mockReset()

Node.jsのTest機能では、以下となる

  • mockFn.mock.restore()

振る舞いが微妙に違うため後述する

+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
 describe('test', () => {
   it('test', () => {
-    const spiedConsoleLog = jest
-      .spyOn(console, 'log')
-      .mockImplementation((param: any) => {
-        return `<${param}>`;
-      });
+    const mockedConsoleLog = mock.method(console, 'log', (param: any) => {
+      return `<${param}>`;
+    });

     console.log('hoge');
-    expect(spiedConsoleLog).toHaveBeenCalledTimes(1);
-    expect(spiedConsoleLog).toReturnWith('<hoge>');
+    assert.deepStrictEqual(mockedConsoleLog.mock.calls.length, 1);
+    assert.deepStrictEqual(mockedConsoleLog.mock.calls[0].result, '<hoge>');

-    spiedConsoleLog.mockReset();
+    mockedConsoleLog.mock.restore();
   });
 });

JestとNode.jsでの振る舞いの差異

但しJestとNode.jsのTest機能では微妙に差異がある

例えばJestでは以下の実装が正しくPASSするが

describe('test', () => {
  it('test', () => {
    const mockFn = jest
      .spyOn(console, 'log')
      .mockImplementation((param: any) => {
        return `<${param}>`;
      });

    console.log('hoge');
    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(mockFn).toReturnWith('<hoge>');

    mockFn.mockReset();

    expect(mockFn).toHaveBeenCalledTimes(0);
    console.log('piyo');
    expect(mockFn).toHaveBeenCalledTimes(1);
    expect(mockFn).not.toHaveBeenCalledWith();
    expect(mockFn).toReturnWith(undefined);
  });
});

Node.jsで以下の実装を書いても同じように機能しない

import { describe, it, mock } from 'node:test';
import assert from 'node:assert';

describe('test', () => {
  it('test', () => {
    const mockFn = mock.method(console, 'log', (param: any) => {
      return `<${param}>`;
    });

    console.log('hoge');
    assert.deepStrictEqual(mockFn.mock.calls.length, 1);
    assert.deepStrictEqual(mockFn.mock.calls[0].result, '<hoge>');

    mockFn.mock.resetCalls();
    mockFn.mock.restore();

    assert.deepStrictEqual(mockFn.mock.calls.length, 0);
    console.log('piyo');
    // 以降のテストはいずれも落ちる
    assert.deepStrictEqual(mockFn.mock.calls.length, 1);
    assert.deepStrictEqual(mockFn.mock.calls[0].result, '<piyo>');
  });
});

但しモック実装を削除したうえで再度呼び出すという行為には意味がないので、特に問題にはならないと思われる

モック機能についての備考

it([name][, options][, fn])の第三引数のコールバックの第一引数にはTestContextが入っており、これを使ってモックすることもできる

これを使う場合、スコープアウトでモックが復元されるため、例えば以下のような関数をテストするときに便利である。

実装

export namespace Hoge {
  export const validateFoo = () => {
    // 例外を飛ばす可能性のある何かの処理
  };

  export const validateBar = () => {
    // 例外を飛ばす可能性のある何かの処理
  };

  export const validateBaz = () => {
    // 例外を飛ばす可能性のある何かの処理
  };

  export const hoge = () => {
    validateFoo();
    validateBar();
    validateBaz();

    return 1;
  };
}

テストコード

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Hoge } from './hoge';

describe('hoge', () => {
  it('validateFooが例外をスローした場合、例外がスローされること', (t) => {
    t.mock.method(Hoge, 'validateFoo', () => {
      throw new Error('foo');
    });

    assert.throws(() => {
      Hoge.hoge();
    }, Error('foo'));
  });

  it('validateBarが例外をスローした場合、例外がスローされること', (t) => {
    t.mock.method(Hoge, 'validateBar', () => {
      throw new Error('bar');
    });

    assert.throws(() => {
      Hoge.hoge();
    }, Error('bar'));
  });

  it('validateBazが例外をスローした場合、例外がスローされること', (t) => {
    t.mock.method(Hoge, 'validateBaz', () => {
      throw new Error('baz');
    });

    assert.throws(() => {
      Hoge.hoge();
    }, Error('baz'));
  });

  it('全ての関数が正常終了した場合、戻り値を返すこと', () => {
    const actual = Hoge.hoge();

    assert.strictEqual(actual, 1);
  });
});
投稿日:
Node.js::その他

Babelとtscどっちがいいのか気になったので調べて見たメモ
結論から言うと基本的にはtscで良い

確認環境

Env Ver
@babel/cli 7.14.5
@babel/core 7.14.6
@babel/preset-env 7.14.7
@babel/preset-typescript 7.14.5
typescript 4.3.2

Babelとは何か?

What is Babel? よりBabelとはJavaScriptのコンパイラと説明されている

Babelがしてくれること

  • 構文の変換
  • corejsを使ったPolyfill
  • ソースコードの変換(codemods
  • その他色々

基本的にはES6+をES6にしてくれると考えれば良さそうです
でもそれって別にtscでもいいいよねって思う

Babelの導入

基本はこれ

npm i -D @babel/core @babel/cli @babel/preset-env

Babelの設定

.babelrcを作ってその中にJSONを書いていく
こんな感じ

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "edge": "17",
          "ie": "11",
          "safari": "11.1"
        },
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ]
  ]
}

Babelの機能について

コンポーネント

@babel/cli

BabelのCLI、これがないと始まらない

@babel/core

Babel本体、CLIがよしなにしてくれる

@babel/preset-env

構文の変換やPolyfillを設定できる

@babel/preset-typescript

TSをトランスパイルしてくれる

BabelとBrowserlist

BabelはBrowserlistの設定を認識して自動でPolyfillを挿入してくれます
なお、Babel 7.4.0以前では設定方法が異なる可能性があります

サンプルコード

IE11をターゲットにした設定のサンプルです
以下のコマンドを流せばPolyfillされたJSが出ることを確認できます
npx babel src -d dest --extensions ".ts"

.babelrc
{
  "presets": [
    ["@babel/preset-env", { "corejs": 3, "useBuiltIns": "usage" }],
    ["@babel/preset-typescript"]
  ]
}
.browserlist
ie 11

Babelとtscでトランスパイルしてみる

割と違うコードが出てきます

元のソース

const sp = new URLSearchParams('?aaa=bbb&ccc');
console.log(sp);
const prm = new Promise((res) => res(true));
console.log(prm);
[...Array(10)].forEach((_, i) => console.log(i));

console.log(globalThis.Date());

export {};

Babel

.babelrc
{
  "presets": [["@babel/preset-env"], ["@babel/preset-typescript"]]
}
ビルド結果
"use strict";

Object.defineProperty(exports, "__esModule", {
    value: true,
});

function _toConsumableArray(arr) {
    return (
        _arrayWithoutHoles(arr) ||
        _iterableToArray(arr) ||
        _unsupportedIterableToArray(arr) ||
        _nonIterableSpread()
    );
}

function _nonIterableSpread() {
    throw new TypeError(
        "Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."
    );
}

function _unsupportedIterableToArray(o, minLen) {
    if (!o) return;
    if (typeof o === "string") return _arrayLikeToArray(o, minLen);
    var n = Object.prototype.toString.call(o).slice(8, -1);
    if (n === "Object" && o.constructor) n = o.constructor.name;
    if (n === "Map" || n === "Set") return Array.from(o);
    if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))
        return _arrayLikeToArray(o, minLen);
}

function _iterableToArray(iter) {
    if (
        (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null) ||
        iter["@@iterator"] != null
    )
        return Array.from(iter);
}

function _arrayWithoutHoles(arr) {
    if (Array.isArray(arr)) return _arrayLikeToArray(arr);
}

function _arrayLikeToArray(arr, len) {
    if (len == null || len > arr.length) len = arr.length;
    for (var i = 0, arr2 = new Array(len); i < len; i++) {
        arr2[i] = arr[i];
    }
    return arr2;
}

var sp = new URLSearchParams("?aaa=bbb&ccc");
console.log(sp);
var prm = new Promise(function (res) {
    return res(true);
});
console.log(prm);

_toConsumableArray(Array(10)).forEach(function (_, i) {
    return console.log(i);
});

console.log(globalThis.Date());

tsc

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "allowJs": true,
    "checkJs": true,
    "sourceMap": true,
    "outDir": "./dist",
    "strict": true,
    "noImplicitAny": true,
    "moduleResolution": "node",
    "isolatedModules": true,
    "baseUrl": ".",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}
ビルド結果
"use strict";
var __spreadArray = (this && this.__spreadArray) || function (to, from) {
    for (var i = 0, il = from.length, j = to.length; i < il; i++, j++)
        to[j] = from[i];
    return to;
};
Object.defineProperty(exports, "__esModule", { value: true });
var sp = new URLSearchParams('?aaa=bbb&ccc');
console.log(sp);
var prm = new Promise(function (res) { return res(true); });
console.log(prm);
__spreadArray([], Array(10)).forEach(function (_, i) { return console.log(i); });
console.log(globalThis.Date());
//# sourceMappingURL=index.js.map

結論

tscで問題ない

tscの方が綺麗なコードが出てきてるので、Polyfill要らなければtscで問題ないです
因みにES6+をES5にするのもできるのでJSのトランスパイルにも使えます

core-jsを使うならBabel

ちょいちょい触っててBabelの利点はcore-jsがあれば.browserlistを使えるので、そこでターゲットを指定してやればPolyfillを勝手に差し込んでくれるところですね

但しCRAではBabelに対する.browserlistはほぼ無価値

あとはCreate React AppはビルドにBabelを採用しているので、FW側でビルドパイプラインがあるときには採用したほうが楽です
(態々書き換える意味もないので)
ただreact-scripts 4.0.3にはcore-jsが入っていないので、基本的に.browserlistを書いたところでPolyfillは入らないため、あんまり存在感はないです