Vitest v3 で TypeScript / React / MSW のテスト基盤を組む
Vitest v3 系で unit / component / integration テストを 1 つの runner に集約する設定パターン — globals / projects 分割 / coverage / msw 併用 / vi.mock / fake timers / playwright との分業を実装メモ化。
検証日: 2026-05-10
使用バージョン:
vitest@3.x対象: Jest からの移行 / 新規プロジェクトのテスト基盤を 1 ファイルで組みたい人
Vitest v3 で unit / component / integration テスト を 1 つの runner にまとめる設定パターン。globals / projects で環境分割、coverage / MSW 併用 / vi.mock / fake timers / Playwright との分業までを整理します。
なぜ Vitest を選ぶか
- Vite と同じ設定を使える(別の transform 設定が要らない)
- Jest 互換 API(
describe/it/expect/vi.fnがほぼそのまま動く) - ESM / TypeScript ネイティブ(transform 設定 0)
- watch / coverage / UI(
vitest --ui) が標準同梱 - multi-project(unit と integration を 1 runner に)
Jest は依然として有力だが、Vite ベースのアプリなら 設定の二重管理を避ける 意味で Vitest が現代的選択。
1. 最小設定
vitest.config.ts を 1 つ置くだけで vitest コマンドが動く。globals: true で describe/it/expect を import 不要に、environment で実行環境を選ぶ:
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true, // describe/it/expect を global に
environment: "node", // または "jsdom" / "happy-dom"
coverage: { provider: "v8", reporter: ["text", "html", "lcov"] },
},
});
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}
}
vitest だけで watch、vitest run で 1 回実行(CI 向け)。
2. unit と component を multi-project で分ける
unit は node、component は jsdom 環境が必要。1 つの config に両方 書ける:
export default defineConfig({
test: {
projects: [
{
name: "unit",
include: ["src/**/*.test.ts"],
environment: "node",
},
{
name: "component",
include: ["src/**/*.test.tsx"],
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
],
},
});
import "@testing-library/jest-dom/vitest"; // toBeInTheDocument 等
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => cleanup()); // 各 test 後に DOM をクリア
vitest --project=component で片方だけ実行も可能。
lifecycle hooks の発火順
setup → describe → beforeAll → 各 it × beforeEach/afterEach → afterAll、と入れ子になる。実行順を取り違えるとリーク・teardown 漏れの原因に。
実用ヒント:
afterEach(() => cleanup())で React Testing Library の DOM をクリア(setupFiles で 1 回書けば全 file に効く)beforeAllで重い fixture(DB / mock server)を立て、afterAllで stop:beforeEachだと毎回 spin-up で遅くなるvi.useFakeTimers()はbeforeEachで in、afterEachでuseRealTimers()を必ずペアに
3. component test の典型
React Testing Library + @testing-library/user-event を使った最小の component test。render で mount、userEvent.click で操作、expect(...).toBeInTheDocument() で確認:
import { render, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("クリックで onClick が呼ばれる", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>送信</Button>);
await userEvent.click(screen.getByRole("button", { name: "送信" }));
expect(onClick).toHaveBeenCalledOnce();
});
it("disabled 時はクリックできない", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick} disabled>送信</Button>);
await userEvent.click(screen.getByRole("button", { name: "送信" }));
expect(onClick).not.toHaveBeenCalled();
});
});
ポイント:
getByRoleをデフォルトに(getByTextより a11y に厚い、Testing Library の最重要原則)userEventをfireEventより優先(キーボードイベントの reality に近い)expect(onClick).toHaveBeenCalledOnce()(jest にはない、vitest 固有の便利 matcher)
4. MSW で network をモック
実装の fetch を mock するより、HTTP レイヤーで止める ほうが現実的。
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
export const server = setupServer(
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({ id: params.id, name: "Alice" });
}),
http.post("/api/login", async ({ request }) => {
const body = await request.json();
if (body.email !== "ok@example.com") {
return new HttpResponse("Invalid", { status: 401 });
}
return HttpResponse.json({ token: "tok_123" });
}),
);
import { server } from "./src/test/msw";
import { afterAll, afterEach, beforeAll } from "vitest";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
各テストで一時的にレスポンスを変える時は:
import { http, HttpResponse } from "msw";
import { server } from "../test/msw";
it("401 ならログイン失敗", async () => {
server.use(http.post("/api/login", () => new HttpResponse("nope", { status: 401 })));
// ... assertions
});
onUnhandledRequest: "error" で mock してない URL にアクセスしたら fail(漏れ検出)。
5. vi.mock(モジュール置き換え)
特定のモジュールを テスト時だけ別実装に差し替える。vi.mock の引数 1 が対象 path、2 が置換実装の factory:
// auth/index.ts を全部 mock
vi.mock("../auth", () => ({
getCurrentUser: vi.fn().mockReturnValue({ id: "u1", role: "admin" }),
}));
// 部分 mock(他は実装を維持)
vi.mock("../utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils")>();
return {
...actual,
fetchData: vi.fn().mockResolvedValue([]),
};
});
vi.hoisted() を使うと mock factory 内で外側変数 を使える(jest と違う癖がある):
const { mockUser } = vi.hoisted(() => ({
mockUser: { id: "u1", name: "Alice" },
}));
vi.mock("../auth", () => ({
getCurrentUser: () => mockUser,
}));
6. Fake Timers(非同期 / setTimeout の制御)
setTimeout / setInterval / debounce を含むコードを、時間を待たずにテストする:
import { vi } from "vitest";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("debounce が 300ms 後に発火する", () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalled();
});
vi.advanceTimersByTime / vi.runAllTimers で時間を進める。new Date() も mock したい時 は vi.setSystemTime(new Date("2026-05-10"))。
7. snapshot test(慎重に)
toMatchInlineSnapshot() で出力をその場で固定。差分が変わったら CI で警告される代わりに、見るべき diff が増えがちなので「変わりにくい構造化データ」に絞って使う:
expect(rendered).toMatchSnapshot();
expect(rendered).toMatchInlineSnapshot(); // ファイル生成せず、source に直書き
ポイント:
- 「変わったら知りたい」 ものだけに使う(全 component に snapshot は anti-pattern)
- HTML 全体より「期待される構造のキー部分」:
expect(button.outerHTML).toMatchInlineSnapshot() - snapshot が変わる PR では 意図的かを必ず diff で確認
8. coverage 計測
V8 ベースの coverage を有効にして、reporter で出力形式を選ぶ(text は console、html は browser 表示、lcov は CI 連携用):
{
test: {
coverage: {
provider: "v8", // istanbul より速い
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["**/*.test.{ts,tsx}", "**/*.d.ts"],
thresholds: {
lines: 80, functions: 80, branches: 70, statements: 80,
},
},
}
}
provider: "v8"が標準推奨(speed)thresholdsで CI で coverage 不足を fail に- HTML レポートは
coverage/index.htmlで確認
9. Playwright との分業
Vitest と Playwright は 役割分担 する:
| Vitest | Playwright | |
|---|---|---|
| 範囲 | 1 component / 1 関数 | ユーザーフロー全体 |
| 環境 | jsdom / node | 実ブラウザ |
| 速度 | 数百 test / 秒 | 1 test / 秒 |
| network | MSW で mock | 実 API or stubbed server |
| 用途 | 80% を担う | クリティカル path のみ(login / 購入 / 投稿) |
「Vitest で広く、Playwright で深く」が定石。Vitest の component test で 90% カバーし、Playwright は 5-10 シナリオに絞る。
つまずいたポイント
describe/itが見つからない:globals: trueを config に書くか、import { describe, it, expect } from "vitest"を毎回- jsdom で
window.matchMediaが undefined:setupFiles で polyfill(Object.defineProperty(window, "matchMedia", ...)) - MSW が listen していない:setup で
server.listen()を beforeAll で呼ぶ、onUnhandledRequest: "error"で漏れ検出 - vi.mock の hoisting:test ファイルの先頭に巻き上げられる。動的な mock factory には
vi.hoisted() - coverage が 0%:
provider: "v8"必須、source map が壊れていると 0 になる、reporterをtextで確認
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門
Python と JavaScriptではじめるデータビジュアライゼーション
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…