📸

`node:test` のスナップショットテスト(--experimental-test-snapshots)を試す

に公開

https://www.mizdra.net/entry/2024/11/22/114114

この記事の中でスナップショットテスト(--experimental-test-snapshots)については名前だけで具体例が無かったのですが、ちょうどJestのスナップショットテストを使っているOSSをメンテしていたのでJest -> node:test に置き換えられるか試してみました。

node:test + TypeScriptのセットアップ

実際のコードを置き換えてみる前に、最小限のコードで node:test とTypeScriptを使ったテストを実行する方法を試してみます。

まずは node --test --experimental-stryp-types でTypeScriptのテストを実行できる環境からセットアップします。実行環境のNode.jsは執筆時点でLTS v22最新のv22.11.0を使用し、import/exportを利用するため "type": "module" でESMにします。package.jsonはこのようになりました。

// package.json
{
  "name": "node_test_snapshot",
  "private": true,
  "type": "module",
  "engines": {
    "node": "=22.11.0"
  },
  "scripts": {
    "test": "node --test --experimental-strip-types 'tests/**/*.test.ts'"
  }
}

最小構成のテストのためにまずは実装側のコードを簡単に用意します。

// src/add.ts
export function add(a: number, b: number): number {
  return a + b;
}

テスト側ではこの add() 関数をimportしてテストします。自分はdescribe, itスタイルが好みなのですが node:test はこのスタイルもサポートしているので以下のように書くことができます。

// tests/add.test.ts
import { add } from '../src/add.ts';
import { describe, it } from 'node:test'; // Cannot find module 'node:test' or its corresponding type declarations.ts(2307)
import assert from 'node:assert/strict'; // Cannot find module 'node:test' or its corresponding type declarations.ts(2307)

describe('add', () => {
  it('1 + 1 = 2', async () => {
    const actual = add(1, 1)
    assert.strictEqual(actual, 2)
  })
})

ここまでのpackage.jsonの構成ではVSCode上で型エラーが出てしまいますが、直し方は後述します。 --experimental-strip-types はNode.jsの実行時に型情報を削除して実行するオプションなので、VSCode上でエラーが出ていても実行自体に問題はありません。

node --test --experimental-strip-types 'tests/**/*.test.ts' を実行するとテストが実行されて成功するはずです。

$ node --test --experimental-strip-types 'tests/**/*.test.ts'
(node:26537) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:26544) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)add1 + 1 = 2 (0.602881ms)add (0.937778ms)
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 61.516127

--experimental-strip-types によってtscやJestなどを使わずとも.tsのテストを直接実行できました。後はVSCode上の型エラーの解消ですが、これはNode.jsの標準ライブラリの型情報が無いためなので @types/node をインストールすれば解消されます。注意点として@types/nodeのバージョンはv22以上をインストールしましょう。今までv20を利用していた環境などで @types/node が古いままだと VSCode上で node:test の型エラーが出てしまってハマることになります(実体験)。

この記事を執筆している時点では @types/node の最新バージョンはv22.10.1だったのでこちらをインストールしました。

// package.json
{
  "name": "node_test_snapshot",
  "private": true,
  "type": "module",
  "engines": {
    "node": "=22.11.0"
  },
  "scripts": {
    "test": "node --test --experimental-strip-types 'tests/**/*.test.ts'"
  },
  "devDependencies": {
    "@types/node": "22.10.1"
  }
}

これでVSCode上の型エラーは出なくなったはずです。

スナップショット(--experimental-test-snapshots)の使い方

やっと本題のスナップショットです。スナップショットの対象は何でもいいのですが、先ほどの add() 関数を使って適当なオブジェクトを作ってそれをスナップショットテストしてみます。

先ほどのテストに雑にスナップショットテストも追加しました。全てのコードを以下に示します。

// tests/add.test.ts
import { add } from '../src/add.ts';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

describe('add', () => {
  it('1 + 1 = 2', async () => {
    const actual = add(1, 1)
    assert.strictEqual(actual, 2)
  })
})

describe('snapshot', () => {
  it('1 + 1 = 2', async (t) => {
    const actual = {
      "expression": "add(1, 1)",
      "result": add(1, 1)
    }
    t.assert.snapshot(actual)
  })
})

スナップショットテストの実行には現状では --experimental-test-snapshots オプションが必要です。これを付けないと TypeError [Error]: t.assert.snapshot is not a function のようなエラーが出てしまいます。オプションを付けて実際に実行してみるとこのようにエラーとなります。

$ npm run test

> test
> node --test --experimental-strip-types --experimental-test-snapshots 'tests/**/*.test.ts'

(node:527768) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:527774) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:527774) ExperimentalWarning: Snapshot testing is an experimental feature and might change at any time
▶ add
  ✔ 1 + 1 = 2 (0.259715ms)
✔ add (0.616412ms)
▶ snapshot
  ✖ 1 + 1 = 2 (0.427018ms)
    Error [ERR_INVALID_STATE]: Invalid state: Cannot read snapshot file '/home/kesin/github/kesin11-private/blog/20241130_node_test_snapshot/tests/add.test.ts.snapshot.' Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag.

Jestなどでスナップショットテストを使ったことがある人はピンと来たと思いますが、まだ一度も実行したことがないので正解となるスナップショットファイルが生成されていないために出ているエラーですね。書かれている通り、--test-update-snapshots オプションを付けて再度実行しましょう。

$ node --test --experimental-strip-types --experimental-test-snapshots --test-update-snapshots 'tests/**/
*.test.ts'
(node:529033) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:529039) ExperimentalWarning: Type Stripping is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
(node:529039) ExperimentalWarning: Snapshot testing is an experimental feature and might change at any timeadd1 + 1 = 2 (0.515685ms)add (0.914312ms)
▶ snapshot
  ✔ 1 + 1 = 2 (0.295125ms)
✔ snapshot (0.350107ms)
ℹ tests 2
ℹ suites 2
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 64.615503

これでスナップショットファイルが生成されました。テストファイルと同じディレクトリに配置されるようなので実際に中身を見てみましょう。

$ ls tests/
add.test.ts  add.test.ts.snapshot

$ cat tests/add.test.ts.snapshot 
exports[`snapshot > 1 + 1 = 2 1`] = `
{
  "expression": "add(1, 1)",
  "result": 2
}
`;

exportsというオブジェクトのキーにテストケース名、値にスナップショット対象のオブジェクトをJSONにした文字列が入っているみたいです。

スナップショットファイルが生成されたので、次回以降は --test-update-snapshots を外して実行すれば前回の実行と比較して差分がある場合にテストがfailとなります。Jestなどと同様に作成されたスナップショットファイルは git commit しておきましょう。

スナップショットの微妙なハマりどころ

先ほどのコードで気が付いたかもしれませんが assert.strictEqualnode:assert からimportしていますが、スナップショットテストは t.assert.snapshot になります。

JestVitestでは expect().toMatchSnapshot() という使い方だったので、それともまた異なる形になっていて少し分かりにくいですね。自分はハマりました。

他にもスナップショットテストのドキュメント中のサンプルコードでは test('テストケース名', (t) => {})t.assert.snapshot の例しか書かれていませんが、実は it でも問題なく t.assert.snapshot は使えたりします。

先ほどのコードは既に it の方を使っています。再掲するとこの部分になります。

describe('snapshot', () => {
  it('1 + 1 = 2', async (t) => {
    const actual = {
      "expression": "add(1, 1)",
      "result": add(1, 1)
    }
    t.assert.snapshot(actual)
  })
})

このような細かい部分に関しては現時点では node:test のドキュメントを探すよりも VSCode上で定義元にジャンプして @types/node の型定義を見た方が早いです。it でも使えることは型定義を見て知りました。

実際にスナップショットテストを含むJestのテストを node:test に移行してみた

非常に小規模なOSSですが、拙作のKesin11/ts-junit2jsonのスナップショットテストをJestから node:test に実際に移行したときのpull-requestがこちらです。
https://github.com/Kesin11/ts-junit2json/pull/347

小規模なOSSなのでもともとdevDependenciesの依存も少なかったですが、Jest関連のパッケージを外すことができたのでさらにシンプルになりました。もうほとんど typescript 本体と型定義のパッケージぐらいにしか依存していないですね。

node:test のちょっとよく分からない点

ここまでは基本的に良い話でしたが、ここからは実際に移行してみてよく分からなかった点を書いていきます。

相対パスでimportする場合に拡張子が.jsではなく.tsにする必要がある?

さきほどのテストの例で import { add } from '../src/add.ts'; というように拡張子に .ts を付けています。しかしバンドラーなどに頼らない純粋なESMの場合はimportの拡張子は .js にする必要があるはずです。実際 .js に書き換えるとテスト実行がエラーになります。

$ node --test --experimental-strip-types --experimental-test-snapshots  'tests/**/*.test.ts'

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '...省略.../src/add.js' imported from ...省略.../tests/add.test.ts

どうやら --experimental-strip-types を使う場合はimportの拡張子を .ts にしないとダメそうです。テストのadd.test.ts内のみimportの拡張子を .ts で書くように気を付ければ問題はないかと思ったのですが、現実には add() のようにテスト対象が簡単な実装であることの方が少ないはずで、普通はadd.tsの中でさらに他のモジュールをこのようにimportしているでしょう。

// add.ts
import { ONE } from "./const.ts"; // 

export function add(a: number, b: number): number {
  // 例としてはだいぶ無理やりですが a + b + 1 を返します
  return a + b + ONE;
}

この場合、const.tsをimportするときにも拡張子を .ts にしないと node --test --experimental-strip-types でテスト実行する場合には先ほどと同様にimportできないエラーになります。しかし、テストではなく普通に tsc で.jsにコンパイルして使う場合には import { ONE } from "./const.js" にしないといけないはずです。何ともかみ合わせが悪い。

この問題はもはやスナップショットテストと関係ない話なので、もっと以前に --experimental-strip-type が追加されたとき誰かが解説してそうだと思ったらやはり先人の方たちが既に書かれていました。

https://blog.koh.dev/2024-10-23-nodejs-builtin-test-typescript/
https://zenn.dev/mizchi/articles/experimental-node-typescript
https://zenn.dev/uhyo/articles/rewrite-relative-import-extensions-read-before-use

自分はjavascript/TypeScriptのimport問題に詳しくない(というか闇が深すぎるのでいい感じになるまで待ちたい)ので、これらの記事を読んだ感想としては test:node 意外とめんどくさそう・・・でした。

自分みたいによっぽど小規模なOSSでimport問題に特にハマることは少ないとか、気合で解決する自信がある人は node:test で頑張ってみてもいいと思いますが、少なくとも現時点では結局JestやVitestに頼った方が面倒ごとは少ないと思います。

まとめ

node:test--experimental-test-snapshots はJestなどと使い方が若干異なるものの、普通にスナップショットテストとして利用できました。一方でTypeScriptのプロジェクトでは node:test を利用する場合は --experimental-strip-types との組み合わせの問題が新たに発生するので、よほどdevDependenciesを減らしたい場合以外はJestやVitestを使った方が楽だと思います。

Discussion