レベル2: DOMベースのコンポーネントテスト
jsdom または happy-dom と Testing Library を使ったコンポーネントテスト。
レベル2がテストするもの
レベル2のテストは、シミュレートされたDOM環境でのコンポーネントの動作を検証します。実際のブラウザなしで、コンポーネントが正しい要素をレンダリングし、ユーザーイベントに応答し、状態を正しく更新することを確認できます。
典型的な対象:
コンポーネントのレンダリング(正しい要素を出力するか?)
条件付き表示(propsや状態に基づいて表示/非表示するか?)
イベントハンドラー(クリックで正しい動作がトリガーされるか?)
propsに基づく動作
コンポーネント間の連携(親子コンポーネントの通信)
ツール
| ツール | 役割 |
|---|---|
| vitest | テストランナー |
| jsdom または happy-dom | シミュレートされたブラウザDOM環境 |
| @testing-library/react | DOMクエリとユーザーイベントシミュレーション |
| @testing-library/preact | Preactプロジェクト向け |
セットアップ
DOM環境を使用するようvitestを設定します:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // or "happy-dom"
globals: true, // required for Testing Library auto-cleanup between tests
},
});Tip
happy-dom はほとんどのケースで jsdom より高速です。より広いブラウザAPI互換性が必要な場合は jsdom を使用してください。いずれにせよ jsdom と happy-dom は Vitest 本体とは別の devDependencies です -- 使う方をインストールしないと、最初の実行が module-not-found エラーで失敗します。
Note
globals: true がないと Testing Library の自動クリーンアップが登録されず、同じファイル内の2つ目のrender()が前のコンポーネントのDOMを残してしまいます(例えばgetByRole("button")が2つのボタンにマッチしてしまう)。globals: trueをプロジェクト全体で有効にしたくない場合は、代わりに明示的にクリーンアップしてください:
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(cleanup);例
// components/Toggle.tsx
import { useState } from "react";
export function Toggle({ label }: { label: string }) {
const [on, setOn] = useState(false);
return (
<button onClick={() => setOn(!on)}>
{label}: {on ? "ON" : "OFF"}
</button>
);
}// components/Toggle.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toggle } from "./Toggle";
describe("Toggle", () => {
it("renders with OFF state", () => {
render(<Toggle label="Sound" />);
expect(screen.getByText("Sound: OFF")).toBeTruthy();
});
it("toggles to ON on click", async () => {
const user = userEvent.setup();
render(<Toggle label="Sound" />);
await user.click(screen.getByRole("button"));
expect(screen.getByText("Sound: ON")).toBeTruthy();
});
});Render-to-String による SSG プレゼンステスト
レベル2の手段は jsdom だけではありません。アイランドや SSG フレームワークには、もう1つの安価なやり方があります。フレームワークの render-to-string で素の Node 上でコンポーネントを文字列にレンダリングし、静的マークアップの契約 -- クローラーや JS オフのユーザーが依存する出力部分 -- をアサートするのです。シミュレートされた DOM 環境がまったく不要なので、おおよそレベル1のコストで動きます。あるドキュメントサイトのプロジェクトでは、これが標準のレベル2レイヤーとなり、27回以上使われました。
典型的な対象は「存在」の契約です。すなわち「これらのリンクは、JavaScript が動く前のサーバーレンダリング済み HTML に存在していなければならない」。
// toc.presence.test.tsx
import { describe, it, expect } from "vitest";
import { renderToString } from "preact-render-to-string";
import { TableOfContents } from "./TableOfContents";
describe("TableOfContents SSG presence", () => {
it("emits every heading link in static markup", () => {
const headings = [
{ id: "intro", text: "Intro" },
{ id: "setup", text: "Setup" },
];
// Rendered under plain Node -- no jsdom, no browser.
const html = renderToString(<TableOfContents headings={headings} />);
// The contract crawlers and JS-off readers rely on:
expect(html).toContain('href="#intro"');
expect(html).toContain('href="#setup"');
});
});これが証明するのは、フレームワークがサーバーで出力する静的な、ハイドレーション前のマークアップです。逆に決して証明できないのは、ブラウザのパース修復、CSS の可視性、イベントの結線、アイランドのハイドレーションのライフサイクル、そしてハイドレーション後の DOM 構造です。それらには実際のブラウザが必要で、ハイドレーション後の失敗の1つはハイドレーションのミスネスト変種として文書化されています。これはハイドレーション待ちを伴うレベル4テストを必要とします。
ブラインドスポット
Warning
レベル2のテストは実際のブラウザの外で動きます。シミュレートされた DOM(jsdom / happy-dom)に対してであれ、DOM をまったく使わない render-to-string のマークアップとしてであれ、どちらの手段も以下を検出できません:
CSSの効果(CSSエンジンが存在しない)
視覚的レイアウト(マークアップに存在しても、CSSにより非表示になる要素)
ブラウザ固有のレンダリング
スクロール動作
アニメーションとトランジションの状態
算出スタイル
重要なギャップ:jsdomのツリーや render-to-string の出力に要素が存在していても(レベル2はパス)、CSSにより画面上では完全に非表示になっている場合があります(レベル5ならこれをキャッチ)。
jsdomでは足りないがフルE2Eには重すぎるとき
jsdomとhappy-domにはCSSエンジンが存在しないため、上のブラインドスポットに挙げたもの -- 算出スタイル、var()やoklch()の解決、実際のレイアウト -- はレベル2のテストからは見えないままです。Vitestのブラウザモードは、レベル2から離れることなくこのギャップだけを埋めます。シミュレートされたDOMの代わりに、コンポーネントテストを実際のPlaywright駆動のChromium内で実行するため、実際のブラウザのCSSエンジンが解決を行うので算出スタイルが本当に解決されます。
テストファイル自体はほとんど変わりません -- 同じdescribe/it、同じコンポーネントレンダリングAPIです。変わるのは、既存のjsdomのunitプロジェクトに加えて、*.browser.test.tsという命名規則にスコープされた2つ目のVitestプロジェクトの裏側の環境です。具体的な設定は2プロジェクト構成のレシピを参照してください。
Note
実例:zdtpはまさにこの分割を採用しています -- コンポーネントテストの大部分はjsdomで、実際の算出スタイルをアサートする必要があるひと握りのテストは*.browser.test.tsを実行するbrowserプロジェクトで行います。
これがレベルモデルのどこに位置するか:コンポーネントの分離、vitestのAPI、起動すべきアプリサーバーがないという、依然としてレベル2のエルゴノミクスのままです。しかしレベル5相当のCSS可観測性を、分離されたライブラリコードに対して持ちます。これは/(英語版のスキルドキュメント: /)の代替にはなりません -- あのスキルは、分離されたコンポーネントではなく実際のページに対するアサーションとなる、フルで動作しているアプリを検証するためのツールであり続けます。
レベル2を使用するタイミング
| シナリオ | レベル2は適切か? |
|---|---|
| コンポーネントが誤ったテキストをレンダリング | はい |
| propsが正しく渡されない | はい |
| クリックハンドラーが状態を更新しない | はい |
| 要素が存在するが表示されない | いいえ -- レベル5を使用 |
| CSSレイアウトが壊れている | いいえ -- レベル5を使用 |
| コンポーネントに対する実際の算出スタイルを分離状態でアサートする | はい -- Vitestブラウザモード経由(上記参照) |
| 複数ページのナビゲーションフロー | いいえ -- レベル4を使用 |