Vitestのテストを高速化する:設定ごとの実行時間を実測で比べる

TypeScript
スポンサーリンク

Vitestのテストが遅いときに効く設定を、同じプロジェクトで実際に切り替えて、実行時間を比べました。Vitest 5.1 が実行結果に出す遅い原因のヒントと、設定ごとの時間を測る vitest doctor も使っています。

結論

今回の確認用プロジェクト(テストファイル80個、DOMのテストファイル30個)を、CPU 4つのコンテナで5回ずつ実行したときの、Vitestが表示する Duration の中央値です。数字はこのプロジェクトとこの環境でのもので、別のプロジェクトで同じ割合で速くなるとは限りません。

やりたいこと設定・オプション今回の結果(中央値)
遅い原因を知る実行結果の Duration の内訳とヒント、npx vitest doctorどちらも isolate: false を勧めた
テストファイルごとの分離をやめる--no-isolate(isolate: false)4.70秒 → 0.54秒
ワーカーをスレッドにする--pool=threads(pool: 'threads')4.70秒 → 3.88秒
DOMのテストを速くする--environment=happy-dom、--pool=vmThreads、--no-isolatejsdom の既定 8.89秒から1つずつ変えると、happy-dom 5.06秒、jsdom + vmThreads 1.52秒、jsdom + isolate: false 1.03秒
一部のテストだけ実行するファイルを指定する(-t だけにしない)-t だけ 4.57秒、ファイル指定 0.24秒
複数のマシンに分ける--shard=1/480ファイルのうち20ファイルを実行して1.30秒

いちばん効いた isolate: false は、テストファイルの間でモジュールの状態が共有されるので、テストによっては結果が変わります(後述)。

動作確認した環境

  • Vitest 5.1.0、happy-dom 21.0.4、jsdom 30.1.3
  • Node.js 26.13.0(Dockerイメージ node:26.13.0-trixie-slim。LTS版)
  • Docker 29.7.2(Windows 11 の Docker Desktop)。コンテナが使うCPUを4つに固定(--cpuset-cpus=0-3)
  • Windowsのホストでの確認だけ: Node.js 22.17.1、PowerShell 7.6.5、Windows PowerShell 5.1
  • 確認日: 2026年11月5日

この記事は、MacとWindowsの両方の読者を対象にしています。計測はDockerのコンテナ(Linux)の中で行ったので、MacでもWindowsでも、DockerにCPUが4つ以上割り当てられていれば同じ手順で試せます。自分のPCで直接Vitestを動かす場合にOSで違う点は、「MacとWindowsで違う点」にまとめました。Windowsはこの記事のPCで確かめ、Macでは確認していません。

この記事のコマンドは、Claude(AI)が実行して確認しました。計測した時間は、同じPCでほかの作業もDockerを使っていたため、多少揺れています。

準備

CPUを4つに固定したコンテナを起動し、その中で作業します。--cpuset-cpus=0-3 は、CPUの番号0〜3を使う指定です。Dockerが使えるCPUが4つ未満だと、この指定では起動できないので、数を減らしてください(その場合は、掲載した数字とは条件が変わります)。WindowsのDocker Desktopは、Linuxコンテナを使う設定にしておきます。

$ docker run --rm -it --cpuset-cpus=0-3 node:26.13.0-trixie-slim bash
# mkdir -p /root/p && cd /root/p
# node -e "console.log(require('os').availableParallelism())"
4

Vitestは、使えるCPUの数(os.availableParallelism())をもとにワーカーの数を決めます(公式ドキュメント)。CPUの数が違うと、結果も変わります。

プロジェクトを作って、Vitestを入れます。npm pkg set type=module は、vitest.config.ts をESモジュールとして読ませるためです。別のコンテナで試したところ、つけない場合(npm init -y の既定は "type": "commonjs" でした)は、実行のたびに ESM syntax in a file loaded as CommonJS (vitest.config.ts:1:1) という警告が出ました。

# npm init -y > /dev/null && npm pkg set type=module && npm install -D vitest@5.1.0 happy-dom@21.0.4 jsdom@30.1.3

added 80 packages, and audited 81 packages in 10s

確認用のファイルは、次のスクリプト(gen.mjs)で作りました。src/index.ts が40個のモジュールをまとめて再エクスポートし、tests/node の80個のテストファイルがそれを読み込みます。tests/dom の30個はDOMを使うテスト、tests/state の2個は後半のハマりどころで使います。vitest.config.ts の dir で、普段は tests/node だけを探すようにしています。

// 確認用のプロジェクトのファイルを作る(記事の「準備」に載せるもの)
import { mkdirSync, writeFileSync } from 'node:fs'

const MODS = 40       // src/lib のモジュール数(src/index.ts でまとめて再エクスポートする)
const NODE_TESTS = 80 // tests/node のテストファイル数
const DOM_TESTS = 30  // tests/dom のテストファイル数
const pad = (i) => String(i).padStart(2, '0')

for (const d of ['src/lib', 'tests/node', 'tests/dom', 'tests/state']) mkdirSync(d, { recursive: true })

for (let i = 1; i <= MODS; i++) {
  writeFileSync(`src/lib/mod${pad(i)}.ts`,
    `export function add${pad(i)}(a: number, b: number): number { return a + b + ${i} }\n` +
    `export function words${pad(i)}(s: string): string[] { return s.split(/\\s+/).filter(Boolean) }\n`)
}
writeFileSync('src/index.ts',
  Array.from({ length: MODS }, (_, i) => `export * from './lib/mod${pad(i + 1)}'`).join('\n') + '\n')
writeFileSync('src/counter.ts', 'let count = 0\nexport function next(): number { count += 1; return count }\n')

for (let i = 1; i <= NODE_TESTS; i++) {
  const m = ((i - 1) % MODS) + 1
  writeFileSync(`tests/node/t${pad(i)}.test.ts`,
    `import { describe, expect, test } from 'vitest'\n` +
    `import { add${pad(m)}, words${pad(m)} } from '../../src/index'\n` +
    `describe('t${pad(i)}', () => {\n` +
    `  test('add', () => { expect(add${pad(m)}(1, 2)).toBe(${3 + m}) })\n` +
    `  test('words', () => { expect(words${pad(m)}(' a  b ')).toEqual(['a', 'b']) })\n` +
    `})\n`)
}
for (let i = 1; i <= DOM_TESTS; i++) {
  writeFileSync(`tests/dom/d${pad(i)}.test.ts`,
    `import { expect, test } from 'vitest'\n` +
    `test('d${pad(i)}', () => {\n` +
    `  const el = document.createElement('div')\n` +
    `  el.textContent = 'd${pad(i)}'\n` +
    `  document.body.append(el)\n` +
    `  expect(document.body.textContent).toContain('d${pad(i)}')\n` +
    `})\n`)
}
for (const name of ['a', 'b']) {
  writeFileSync(`tests/state/${name}.test.ts`,
    `import { expect, test } from 'vitest'\n` +
    `import { next } from '../../src/counter'\n` +
    `test('${name}: 最初の next() は 1', () => { expect(next()).toBe(1) })\n`)
}
writeFileSync('vitest.config.ts',
  `import { defineConfig } from 'vitest/config'\n\n` +
  `export default defineConfig({\n  test: {\n    dir: 'tests/node',\n  },\n})\n`)
console.log('generated')
# node gen.mjs
generated
# npx vitest --version
vitest/5.1.0 linux-x64 node-v26.13.0

以降は、別に確かめたと書いた章を除き、準備のあとに上から順に実行した結果です。出力の色は消し(NO_COLOR=1)、テストファイルごとの行は省略しています。

まず1回実行して、遅い原因を見る

# npx vitest run
(テストファイルごとの80行は省略)
 Test Files  80 passed (80)
      Tests  160 passed (160)
   Start at  11:29:31
   Duration  6.14s (import 49%, transform 30%, tests 10%, worker 10%)

    Isolate  80 workers spawned · ~147ms startup each (spawn + environment, per file)
             at least ~3.76s faster with isolate: false — reuses workers across files instead of one per file

Duration の括弧の中は、時間の内訳です。テストそのもの(tests)は10%で、ほとんどはモジュールの読み込み(import)と変換(transform)でした。公式ドキュメントによると、この割合は並列に動くワーカーの時間を合計した中での割合で、実際の経過時間の割合ではありません。

その下の Isolate の行は、Vitestが出したヒントです。テストファイルごとに80個のワーカーを起動していて、isolate: false にすると少なくとも約3.76秒速くなる、と見積もっています。

vitest doctor で、設定を変えたときの時間を測る

vitest doctor は、いくつかの設定で実際にテストを実行して、時間を比べるコマンドです。

# npx vitest doctor

 DOCTOR  resolving the current configuration...

Measuring alternative configurations by running the test suite under each of them.
Close other heavy programs - the comparison is only as good as the machine is quiet.

  measuring baseline (pool: forks · isolate: true)... 5.43s
  measuring pool: 'threads'... 3.81s
  measuring isolate: false... 0.78s

Results (min of 3 runs each)

  baseline (pool: forks · isolate: true)  5.43s
  pool: 'threads'                         3.81s (-30%)
  isolate: false                          0.78s (-86%)

Recommendation: isolate: false (-86%)

  // vitest.config.ts
  import { defineConfig } from 'vitest/config'

  export default defineConfig({
    test: {
      isolate: false, // measured -86% on this suite
    },
  })

  The suite passed twice with a shuffled file order under shared state, so it is
  likely - but not guaranteed - that no test depends on isolation.
  Trade-offs of every option: https://vitest.dev/guide/improving-performance

ファイルの順番を入れ替えて2回通ったので、分離に頼るテストはおそらくない、ただし保証はしない、という結果です。

設定ごとに比べる

設定ごとの差をもう少し細かく見るため、次のスクリプト(measure.sh)で、13の設定を5回ずつ実行しました。1回目に全部の設定を順に、2回目も全部の設定を順に、と交互に回して、Vitestが表示する Duration の中央値を出しています。

#!/bin/bash
# 設定ごとに vitest run を ROUNDS 回ずつ実行し、Vitest が表示する Duration の中央値を出す(記事の「設定ごとに比べる」に載せるもの)
# 偏りを減らすため、1回目は全部の設定を順に、2回目も全部の設定を順に…と交互に回す
ROUNDS=${ROUNDS:-5}
export NO_COLOR=1
CONFIGS=(
  "node 既定(forks、isolate: true)|"
  "node pool: threads|--pool=threads"
  "node isolate: false|--no-isolate"
  "node threads + isolate: false|--pool=threads --no-isolate"
  "node fileParallelism: false|--no-file-parallelism"
  "dom jsdom|--dir tests/dom --environment=jsdom"
  "dom happy-dom|--dir tests/dom --environment=happy-dom"
  "dom jsdom + pool: vmThreads|--dir tests/dom --environment=jsdom --pool=vmThreads"
  "dom jsdom + isolate: false|--dir tests/dom --environment=jsdom --no-isolate"
  "絞り込み -t だけ|-t t01"
  "絞り込み ファイル指定|tests/node/t01.test.ts"
  "絞り込み -t + preParse|--experimental.preParse -t t01"
  "シャード 1/4|--shard=1/4"
)
: > /tmp/results.tsv
for r in $(seq 1 "$ROUNDS"); do
  for c in "${CONFIGS[@]}"; do
    label=${c%%|*}; args=${c#*|}
    npx vitest run $args > /tmp/out.txt 2>&1; code=$?
    ms=$(grep -m1 -E '^ +Duration' /tmp/out.txt | awk '{v=$2; if (v ~ /ms$/) {sub(/ms/,"",v); print v} else {sub(/s/,"",v); print v*1000}}')
    files=$(grep -m1 -E '^ +Test Files' /tmp/out.txt | sed -E 's/ +Test Files +//')
    printf '%s\t%s\t%s\t%s\t%s\n' "$label" "$r" "$code" "$ms" "$files" >> /tmp/results.tsv
  done
done
echo "## 各回の結果(設定、回、終了コード、Duration[ms]、Test Files)"
cat /tmp/results.tsv
echo
echo "## 中央値(Duration[ms])"
for c in "${CONFIGS[@]}"; do
  label=${c%%|*}
  med=$(awk -F'\t' -v l="$label" '$1==l {print $4}' /tmp/results.tsv | sort -n | awk '{a[NR]=$1} END {if (NR%2) print a[(NR+1)/2]; else print (a[NR/2]+a[NR/2+1])/2}')
  min=$(awk -F'\t' -v l="$label" '$1==l {print $4}' /tmp/results.tsv | sort -n | head -1)
  max=$(awk -F'\t' -v l="$label" '$1==l {print $4}' /tmp/results.tsv | sort -n | tail -1)
  printf '%s\t中央値 %s\t最小 %s\t最大 %s\n' "$label" "$med" "$min" "$max"
done

このスクリプトを /root/p/measure.sh として保存し、同じディレクトリで実行します。13の設定を5回ずつなので、今回は5分ほどかかりました。

# bash measure.sh

結果です(単位は秒。テストはすべて成功しました)。

対象設定中央値最小〜最大
tests/node(80ファイル)既定(forks、isolate: true)4.704.40〜5.01
tests/nodepool: threads3.883.58〜4.17
tests/nodeisolate: false0.540.47〜0.59
tests/nodethreads + isolate: false0.400.39〜0.60
tests/nodefileParallelism: false12.5211.93〜12.61
tests/dom(30ファイル)jsdom8.897.16〜9.23
tests/domhappy-dom5.064.91〜5.55
tests/domjsdom + pool: vmThreads1.521.43〜1.65
tests/domjsdom + isolate: false1.030.92〜1.19
絞り込み-t t01 だけ4.574.35〜5.05
絞り込みファイル指定0.240.21〜0.28
絞り込み-t t01 + --experimental.preParse0.210.21〜0.27
シャード--shard=1/41.301.15〜1.31

isolate: false

既定では、テストファイルごとに別のワーカーで実行されます(isolate: true)。isolate: false にすると、ワーカーをファイル間で使い回すので、ワーカーの起動とモジュールの読み込みが減ります。今回は4.70秒が0.54秒になりました。代わりに、同じワーカーで動いたファイルの間でモジュールの状態が共有されます(後述)。

pool: ‘threads’

既定の forks は子プロセス、threads はワーカースレッドでテストを動かします。今回は4.70秒が3.88秒になりました。公式ドキュメントによると、threads では process.chdir() などプロセスに関わるAPIが使えず、Prismaやbcryptなどネイティブのライブラリでは問題が起きることがあります。

fileParallelism: false は、今回は遅くなった

公式ドキュメントには、プロジェクトによっては並列をやめると起動が速くなることがある、とあります。今回のプロジェクトでは、ファイルを1つずつ実行することになり、12.52秒と一番遅くなりました。

DOMのテスト

jsdomやhappy-domの環境は、既定ではテストファイルごとに作られます(公式ドキュメント)。jsdomの既定の8.89秒から、設定を1つずつ変えて比べました。重ねて変えたのではありません。環境をhappy-domに変えると5.06秒、jsdomのまま pool: 'vmThreads' にすると1.52秒、jsdomのまま isolate: false にすると1.03秒でした。

公式ドキュメントによると、vmThreads はワーカーごとに環境を1回作り、ファイルごとに新しいVMの中で実行します。ただし、instanceof の判定がずれることがあるなどの注意点があります。また、vmThreads では isolate の設定は効きません。

一部のテストだけ実行するときは、ファイルを指定する

テスト名で絞り込む -t だけを使うと、実行されたのは1ファイルでも、80ファイルすべてを読み込みました。テスト名は、ファイルを読み込まないと分からないためです(公式ドキュメント)。

# npx vitest run -t t01
(テストファイルごとの80行は省略)
 Test Files  1 passed | 79 skipped (80)
      Tests  2 passed | 158 skipped (160)
   Start at  11:34:43
   Duration  4.41s (import 68%, transform 17%, worker 15%, environment 1%)

ファイルを指定すると、そのファイルだけを読み込みます。ファイルが分からないときは、テストファイルを解析して名前を探す --experimental.preParse(実験的な機能)も使えました。

# npx vitest run tests/node/t01.test.ts

 RUN  v5.1.0 /root/p

 ✓ tests/node/t01.test.ts (2 tests) 4ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  11:34:49
   Duration  240ms (transform 55%, import 36%, worker 5%, tests 4%)

# npx vitest run --experimental.preParse -t t01

 RUN  v5.1.0 /root/p

 ✓ tests/node/t01.test.ts (2 tests) 4ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  11:34:51
   Duration  292ms (transform 50%, import 40%, worker 7%, tests 3%)

シャーディングは、複数のマシンで分けるときに使う

--shard=1/4 は、テストファイルを4つに分けた1つ目だけを実行します。80ファイルのうち20ファイルが実行されました。

# npx vitest run --shard=1/4
(テストファイルごとの20行は省略)
 Test Files  20 passed (20)
      Tests  40 passed (40)
   Start at  11:34:52
   Duration  1.42s (import 56%, transform 21%, worker 12%, tests 11%)

4つのシャードをCIの別々のマシンで動かせば、全体の時間を短くできます。公式ドキュメントでは、各マシンで --reporter=blob をつけて実行し、最後に --merge-reports で結果をまとめる方法が紹介されています。複数のマシンで動かすところと、結果をまとめるところは、確認していません。

ハマりやすい点: isolate: false で、モジュールの状態が共有される

src/counter.ts は、呼ぶたびに1ずつ増える数を返します。tests/state の2つのファイルは、どちらも「最初の next() は1」を確かめます。

# cat src/counter.ts tests/state/a.test.ts
let count = 0
export function next(): number { count += 1; return count }
import { expect, test } from 'vitest'
import { next } from '../../src/counter'
test('a: 最初の next() は 1', () => { expect(next()).toBe(1) })

既定では、どちらのファイルも成功しました。

# npx vitest run --dir tests/state

 RUN  v5.1.0 /root/p

 ✓ tests/state/b.test.ts (1 test) 5ms
 ✓ tests/state/a.test.ts (1 test) 9ms

 Test Files  2 passed (2)
      Tests  2 passed (2)
   Start at  11:34:56
   Duration  264ms (transform 58%, import 20%, worker 11%, tests 10%)

isolate: false にして、確実に同じワーカーで続けて動くように並列もやめると、2つ目のファイルが失敗しました。1つ目のファイルで増えた数が、そのまま残っていたためです。

# npx vitest run --dir tests/state --no-isolate --no-file-parallelism

 RUN  v5.1.0 /root/p

 ✓ tests/state/a.test.ts (1 test) 4ms
 ❯ tests/state/b.test.ts (1 test | 1 failed) 5ms
   × b: 最初の next() は 1 5ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/state/b.test.ts > b: 最初の next() は 1
AssertionError: expected 2 to be 1 // Object.is equality

(中略)

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 1 passed (2)
   Start at  11:34:57
   Duration  177ms (import 40%, transform 29%, tests 21%, worker 9%, environment 1%)

並列のままだと、2つのファイルが同じワーカーに入るかどうかで結果が変わりえます。isolate: false にするのは、モジュールの状態やグローバルな値を後片付けしているテストだけにします。公式ドキュメントでは、projects を使って、一部のファイルだけ分離をやめる方法も紹介されています。

MacとWindowsで違う点

Vitestのコマンドとオプションは、OSによらず同じです。この章は、コンテナではなくWindows 11 のホストのPowerShellで、別に確かめました(Node.js 22.17.1)。作業用のフォルダで、コンテナと同じ準備をしています(DOMのライブラリは入れていません)。gen.mjs は、このフォルダに置きました。

PS> npm init -y | Out-Null; npm pkg set type=module; npm install -D vitest@5.1.0
PS> node gen.mjs
generated
PS> npx vitest --version
vitest/5.1.0 win32-x64 node-v22.17.1

PowerShell 7.6 とWindows PowerShell 5.1 の両方で、npx vitest run --no-isolate と、\ で区切ったパスでのファイル指定が動きました。次はPowerShell 7.6 の出力です。

PS> npx vitest run --no-isolate | Select-String "Test Files|Duration"

 Test Files  80 passed (80)
   Duration  2.06s (transform 80%, import 18%, worker 1%, tests 1%)
PS> npx vitest run tests\node\t01.test.ts | Select-String "Test Files|Duration"

 Test Files  1 passed (1)
   Duration  373ms (transform 63%, import 31%, worker 3%, tests 2%)

違いが出たのは、環境変数の書き方です。公式ドキュメントにある NODE_COMPILE_CACHE(Node.jsのコンパイル結果をディスクに残して次回以降に使う設定)で確かめました。

場面Mac(ターミナル)/ LinuxWindows(PowerShell)
コマンドの前で設定するNODE_COMPILE_CACHE=node_modules/.cache/ncc npx vitest run$env:NODE_COMPILE_CACHE = 'node_modules/.cache/ncc'; npx vitest run
npm scripts に書く"test:ncc": "NODE_COMPILE_CACHE=node_modules/.cache/ncc vitest run" で動いた(Linuxで確認)同じ書き方はエラーになった

PowerShellの $env: で設定すると、node_modules/.cache の下にキャッシュのフォルダができ、環境変数が効いていることを確認できました。この設定は、そのPowerShellを閉じるまで残ります。

npm scripts に 変数=値 コマンド の形で書くと(npm pkg set で登録)、Windowsでは次のエラーになりました。npm scripts は、Windowsでは既定でコマンドプロンプト(cmd.exe)で実行されるためです(npmの公式ドキュメント)。

PS> npm pkg set "scripts.test:ncc=NODE_COMPILE_CACHE=node_modules/.cache/ncc vitest run"
PS> npm run test:ncc

> vitest-win@1.0.0 test:ncc
> NODE_COMPILE_CACHE=node_modules/.cache/ncc vitest run

'NODE_COMPILE_CACHE' is not recognized as an internal or external command,
operable program or batch file.

MacとWindowsの両方で同じ npm scripts を使うなら、--no-isolate のようなオプションか vitest.config.ts で設定し、環境変数に頼らないほうが安全です。Macのターミナルでは確認していませんが、環境変数の書き方はLinuxと同じ想定です。NODE_COMPILE_CACHE でどれだけ速くなるかは、測っていません。

参考

スポンサーリンク
TypeScriptVitest
スポンサーリンク
シェアする
hayoをフォローする

コメント

タイトルとURLをコピーしました