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-isolate | jsdom の既定 8.89秒から1つずつ変えると、happy-dom 5.06秒、jsdom + vmThreads 1.52秒、jsdom + isolate: false 1.03秒 |
| 一部のテストだけ実行する | ファイルを指定する(-t だけにしない) | -t だけ 4.57秒、ファイル指定 0.24秒 |
| 複数のマシンに分ける | --shard=1/4 | 80ファイルのうち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.70 | 4.40〜5.01 |
| tests/node | pool: threads | 3.88 | 3.58〜4.17 |
| tests/node | isolate: false | 0.54 | 0.47〜0.59 |
| tests/node | threads + isolate: false | 0.40 | 0.39〜0.60 |
| tests/node | fileParallelism: false | 12.52 | 11.93〜12.61 |
| tests/dom(30ファイル) | jsdom | 8.89 | 7.16〜9.23 |
| tests/dom | happy-dom | 5.06 | 4.91〜5.55 |
| tests/dom | jsdom + pool: vmThreads | 1.52 | 1.43〜1.65 |
| tests/dom | jsdom + isolate: false | 1.03 | 0.92〜1.19 |
| 絞り込み | -t t01 だけ | 4.57 | 4.35〜5.05 |
| 絞り込み | ファイル指定 | 0.24 | 0.21〜0.28 |
| 絞り込み | -t t01 + --experimental.preParse | 0.21 | 0.21〜0.27 |
| シャード | --shard=1/4 | 1.30 | 1.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(ターミナル)/ Linux | Windows(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 でどれだけ速くなるかは、測っていません。

コメント