お知らせ

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

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::Jest開発::テスト

Jestで無限ループ処理をテストするときにループから抜けられなくて困ったのでメモ。

参考までにこの記事ではイベントループのように脱出手段のある無限ループを想定しています。

確認環境

Env Ver
@types/jest 29.5.7
jest 29.7.0
typescript 5.2.2

サンプルコード

テスト対象のコード

import { getHandle } from './lib/get-handle';

export const main = () => {
  for (;;) {
    const handle = getHandle();
    if (handle === 'Click') {
      console.log('click');
    } else if (handle === 'KeyDown') {
      console.log('keydown');
    } else {
      break;
    }
  }
};

テストコード

.mockReturnValue()ではなく.mockReturnValueOnce()を使うのが肝です。

.mockReturnValue()だと同じ値が毎回返るので無限ループから抜けられませんが、.mockReturnValueOnce()は一回だけなので抜けられます。チェーンすると二回目、三回目の返り値も設定できます。

なお、以下の例では.mockReturnValueOnce()をチェーンさせていますが、させなくてもテストとしては正常に動作します。指定がない場合はundefinedが返るようです。(基本的には明示的に指定しておいた方が良いと考えています。

import { main } from '.';
import * as getHndl from './lib/get-handle';

jest.mock('./lib/get-handle');

describe('main', () => {
  it('Clickイベントの分岐に入ること', () => {
    jest
      .spyOn(getHndl, 'getHandle')
      .mockReturnValueOnce('Click')
      .mockReturnValueOnce('');

    const spiedConsoleLog = jest.spyOn(console, 'log');

    main();

    expect(spiedConsoleLog).toHaveBeenCalledWith('click');
  });
});

余談

.toHaveBeenCalledWith().toEqual()同じロジックで判定しているらしいので、.toStrictEqual()版もあると便利な気がしました。気が向いたらPR出してみようかな…。

参考

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の組み合わせであればテストはできるが肝心の実行ができず無意味だった

TypeScript + SWCと組み合わせてJestを回してるとテストケースの増加に伴いメモリリークが発生する現象が起きます。メモリに余裕があれば問題にはならないですが、開発機のメモリが足りないとかCIで使ってるECSのメモリが足りないとか、引っかかるケースもあると思います。

今回はこの問題に対する対処法を書いていきます。完全には改善されませんが、かなりマシになります。参考までに今回の検証では283MB使ってたのが67MBまで減りました。

この現象はNode.js 16.11.0以降で発生するらしいので、それ以前の環境では起きないかもしれません。

確認環境

Node.js v20.0.0

module version
@swc/cli 0.1.62
@swc/core 1.3.59
@swc/jest 0.2.26
@types/jest 29.5.1
@types/node 20.2.3
jest 29.5.0
jest-watch-typeahead 2.2.2
typescript 5.0.4

確認用のテストコード

以下のテストコードを書いたファイルを100ファイル作り、それを流して確認しています。

describe('example1', () => {
  it('1', () => {
    expect(1).toBe(1);
  });
  it('2', () => {
    expect(2).toBe(2);
  });
  it('3', () => {
    expect(3).toBe(3);
  });
  it('4', () => {
    expect(4).toBe(4);
  });
  it('5', () => {
    expect(5).toBe(5);
  });
});

describe('example2', () => {
  it('1', () => {
    expect(1).toBe(1);
  });
  it('2', () => {
    expect(2).toBe(2);
  });
  it('3', () => {
    expect(3).toBe(3);
  });
  it('4', () => {
    expect(4).toBe(4);
  });
  it('5', () => {
    expect(5).toBe(5);
  });
});

確認環境一式

以下のリポジトリに確認したソースコードを一式格納しています。

[blogcard https://github.com/Lycolia/jest-memory-leak-example]

メモリリークしていく様子

(41 MB heap size)
(42 MB heap size)
(52 MB heap size)
(51 MB heap size)
(37 MB heap size)
(47 MB heap size)
(47 MB heap size)
(50 MB heap size)
(60 MB heap size)
(60 MB heap size)
(62 MB heap size)
(71 MB heap size)
(73 MB heap size)
(74 MB heap size)
(84 MB heap size)
(85 MB heap size)
(86 MB heap size)
(96 MB heap size)
(97 MB heap size)
(99 MB heap size)
(108 MB heap size)
(110 MB heap size)
(111 MB heap size)
(120 MB heap size)
(122 MB heap size)
(124 MB heap size)
(75 MB heap size)
(83 MB heap size)
(84 MB heap size)
(86 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(107 MB heap size)
(108 MB heap size)
(109 MB heap size)
(118 MB heap size)
(120 MB heap size)
(121 MB heap size)
(130 MB heap size)
(132 MB heap size)
(133 MB heap size)
(143 MB heap size)
(144 MB heap size)
(145 MB heap size)
(154 MB heap size)
(156 MB heap size)
(157 MB heap size)
(166 MB heap size)
(168 MB heap size)
(169 MB heap size)
(179 MB heap size)
(181 MB heap size)
(182 MB heap size)
(191 MB heap size)
(193 MB heap size)
(194 MB heap size)
(203 MB heap size)
(205 MB heap size)
(207 MB heap size)
(216 MB heap size)
(217 MB heap size)
(219 MB heap size)
(228 MB heap size)
(230 MB heap size)
(231 MB heap size)
(240 MB heap size)
(242 MB heap size)
(243 MB heap size)
(252 MB heap size)
(254 MB heap size)
(255 MB heap size)
(264 MB heap size)
(266 MB heap size)
(267 MB heap size)
(277 MB heap size)
(278 MB heap size)
(280 MB heap size)
(195 MB heap size)
(204 MB heap size)
(203 MB heap size)
(212 MB heap size)
(213 MB heap size)
(214 MB heap size)
(223 MB heap size)
(224 MB heap size)
(226 MB heap size)
(235 MB heap size)
(237 MB heap size)
(238 MB heap size)
(247 MB heap size)
(249 MB heap size)
(250 MB heap size)
(259 MB heap size)
(261 MB heap size)
(262 MB heap size)
(271 MB heap size)
(273 MB heap size)
(274 MB heap size)
(283 MB heap size)

Test Suites: 100 passed, 100 total
Tests:       1000 passed, 1000 total
Snapshots:   0 total
Time:        11.347 s, estimated 12 s

解消方法

npm i -D @side/jest-runtime などで@side/jest-runtimeを導入し、jest.config.jsに以下の行を追加することで改善します。

  runtime: '@side/jest-runtime',

このコードで何か既存の実装やテストに影響が発生するかどうかは確認していませんが、後述する@side/jest-runtimeが生まれる切欠になったPRの様子を見る限り大丈夫なんじゃないかなとなんとなく思っています。

メモリリークが改善したあとの様子

(39 MB heap size)
(39 MB heap size)
(47 MB heap size)
(47 MB heap size)
(34 MB heap size)
(42 MB heap size)
(41 MB heap size)
(49 MB heap size)
(51 MB heap size)
(59 MB heap size)
(59 MB heap size)
(60 MB heap size)
(68 MB heap size)
(69 MB heap size)
(77 MB heap size)
(77 MB heap size)
(85 MB heap size)
(86 MB heap size)
(95 MB heap size)
(95 MB heap size)
(95 MB heap size)
(103 MB heap size)
(104 MB heap size)
(112 MB heap size)
(112 MB heap size)
(120 MB heap size)
(121 MB heap size)
(31 MB heap size)
(38 MB heap size)
(37 MB heap size)
(44 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(62 MB heap size)
(70 MB heap size)
(71 MB heap size)
(79 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(105 MB heap size)
(105 MB heap size)
(31 MB heap size)
(39 MB heap size)
(37 MB heap size)
(44 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(62 MB heap size)
(70 MB heap size)
(70 MB heap size)
(78 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(96 MB heap size)
(104 MB heap size)
(105 MB heap size)
(31 MB heap size)
(39 MB heap size)
(37 MB heap size)
(45 MB heap size)
(46 MB heap size)
(54 MB heap size)
(54 MB heap size)
(54 MB heap size)
(62 MB heap size)
(63 MB heap size)
(70 MB heap size)
(71 MB heap size)
(78 MB heap size)
(79 MB heap size)
(87 MB heap size)
(88 MB heap size)
(96 MB heap size)
(96 MB heap size)
(97 MB heap size)
(105 MB heap size)
(105 MB heap size)
(30 MB heap size)
(37 MB heap size)
(45 MB heap size)
(43 MB heap size)
(44 MB heap size)
(51 MB heap size)
(51 MB heap size)
(59 MB heap size)
(59 MB heap size)
(67 MB heap size)

Test Suites: 100 passed, 100 total
Tests:       1000 passed, 1000 total
Snapshots:   0 total
Time:        9.071 s, estimated 11 s

参考記事

メモリリークバグに関するIssue
https://github.com/jestjs/jest/issues/11956

@side/jest-runtimeが出来る元になったPR
https://github.com/jestjs/jest/pull/12205

投稿日:
言語::C#開発::テスト

Windows向けのツールを作ろうというので、昔よく使っていたC#.NETで開発をすることにしたのですが結構苦労があったのでその話を書いていこうと思います。Unit testingを書くのは一旦断念しましたが、暇があれば挑戦したいとは思ってます。

事のいきさつ

WindowsのVSCodeでRemote Development拡張を使うことが多いのですが、リモートエクスプローラーにアイテムが増えてくると移動したいフォルダを探すのが大変になってくるのと、ここに出てないフォルダを開くのが結構手間という問題があり、これを解決できないかということを考えていました。

そこで考えついたのがWSLやSSHのネットワークパス上でExplorerの右クリックメニューを開いたらVSCodeがリモートモードで起動できると良いのでは?という所でした。Windows向けでexeからバイナリ起動するならC#で書くのが楽だろうと考え、C#での開発に着手しました。

疎結合な実装でUnit testingもできたらいいなーと漠然と考えながら開発していたのですが、最後にC#を書いたのは3年ほど前で、C#でモダンな実装をした経験もなかったので結構苦労しました。よく考えたらTypeScriptかJavaScriptくらいでしかまともにやったことがないし、UT自体他言語でもGolangかPHPでしか書いたことがなかったので、C#でこれをやるのは自分の実装技術に対する一種の挑戦みたいなところがありました。結論としてはあっけなく破れたわけですが…。

ツールの要件

  • Explorerの右クリックメニューから起動する
  • 開いたパスに応じてWindowsローカル、SSH、WSLを判定し、適切なモードでVSCodeを起動する
  • パス解決には設定ファイルを用いる
    • Explorerから渡されるパスはリモート環境のパスと差異があるので、そこを解決するためのものです

ツールの設計

やることは設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけです

大まかなフロー

大まかな動作フロー

実行形式

  • コマンドライン引数
    • this.exe <dir path>
  • 設定ファイル形式
    ```json5
    {
    "CodePath": "VSCodeのexeパス",
    // リモートホスト定義。辞書形式
    "Remote": {
    "リモートホスト名": {
    "ExplorerPrefix": "explorerのパスから除外する文字列",
    "AppendPrefix": "explorerのパスに追加する文字列"
    },
    }
    }
    ```

ツールの実装

まずはTDD的にやろうとしてInterfaceを作成し、Classで実装し、ロジック自体は好調に組み上がっていき、テストもすべて通るようになりました。但し実際にexeを蹴ると動かず、テストは通るが動かないゴミが出来上がっていました。(途中で動作確認をしなかったのか?と思うかもしれませんが、実際は完全に動くものを作ってからテスト可能な形に書き換えるという流れで作っていたのでこうなっています)

動かなかった理由は設定ファイルにJSONを採用していて、デシリアライザにSystem.Text.Json.JsonSerializerを使っていたためです。このデシリアライザは標準ではInterfaceに対してJSONをデシリアライズできません(考えてみれば当然ですが)。しかし、テストをするためにはモックをDIしたいので、Interfaceが必要です。

以下は実際に設定を読み込むために実装したClassですが、メンバが全部Interfaceになっているので、標準の状態ではデシリアライズに失敗します。ならばデシリアライザを自作すればいいという話が出てくるのですが、高々設定ファイルを読み込む処理にそこまで情熱を込めるか…?と言うことになり、諦めました。

public class ConfigBase : IConfigBase {
    private IFileInfo? _CodePath;
    private IDictionary<string, IConfigRemote>? _Remote;

    public IFileInfo CodePath {
        get {
            if (this._CodePath == null) {
                throw new Exception("Config Error: Missing CodePath.");
            } else if (this._CodePath == null) {
                throw new Exception("Config Error: CodePath is empty. Set the code.exe path in this field.");
            } else if (!this._CodePath.Exists) {
                throw new Exception("Config Error: CodePath is Not exists.");
            } else {
                return this._CodePath;
            }
        }
        set { this._CodePath = value; }
    }

    public IDictionary<string, IConfigRemote> Remote {
        get {
            if (this._Remote == null) {
                throw new Exception("Config Error: Missing Remote.");
            } else if (this._Remote.Count == 0) {
                throw new Exception("Config Error: Remote is empty. Set the remote infomation in this field.");
            } else {
                return this._Remote;
            }
        }
        set { this._Remote = value; }
    }
}

そもそもこのツールは「設定ファイルを読み込み、コマンドライン引数から値を取得し、それらをいい感じに変換してVSCodeに渡すだけ」のツールです。たったそれだけのツールに入れる仕組みにしては大げさすぎると感じました。

テストをするためにInterfaceを作ったり、本実装とモック用のClassを作る程度まではまだ容認できるのですが、カスタムデシリアライザを作ると、今度はそれのテストも必要になってきます。どう考えてもしんどい。

因みにこのコード、仮にInterfaceをやめても文字列をFileInfoに組み替えるためのカスタムデシリアライザの実装が必要で結構頭が痛くなります…。多分そこはFile.existsをラップしたクラスをDIしてやるのが無難な気がしますね。

この辺はTypeScriptだとimportの中身をJestで書き換えたら終わりなのであんま考えなくていいのは楽ですが、C#だと厳しいなと感じました。

C#でテストを書いていて思ったこと

クラスベースで実装していくとメソッド単位でのテストがしづらく、テストがコケても原因が把握しづらいというのを一つ課題感として覚えました。TSでなんちゃって関数型開発をしていれば関数のUTを書けば関数の挙動を把握できますが、Classではそうも行きません。

仮に1つのpublic methodが5つのprivate methodを呼び出していて、private methodの裏ではprivate propertyが複雑な依存を持っていたとしたらどうでしょうか?
正直デバッグをかけないとどこで何がコケたか特定できないと思います。そんな実装にするのが悪いといえばそうですが、でもクラスってそういうものじゃないですっけ…?お互いに関連性がなくていいならもうそれ関数で良くないですか?って思いました。

勿論、全部静的メソッドにしてClassそのものは単なるエンティティにするのも選択肢の一つだとは思います。しかしC#でそこまでやるか…?という疑念が個人的にあるのと、処理を繋げたテストをUTとして書く方法がなくなると思います。例えばインスタンスメソッドならモックをDIすることでメソッドが呼ばれた事や、戻り値に対する分岐を確認できますが、静的メソッドでこれをやるのは難しいと思います。

Classの単体テストは関数と状態が密結合したテストになってしまうので、いまいち微妙だなと思ったのが今回思ったことでした。