お知らせ

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

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]を嚙ます必要がある。噛まし方はswc/jestでESM, CJS混在のコードをJestを使ってテストする有力な方法は今のところ多分ないに書いた。

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

2024-10-15追記

Node.js v22.3.0でテスト走行時に--experimental-test-module-mocksを渡すことで近いことができるようになった模様だが、試したところ上手く動かないし、spy的な使い方はできなさそうだ。mock.method()との組み合わせも試してみたが、上手くいかなかった。

mock.module() (nodejs.org)

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);
  });
});

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}}