zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

レベル2: DOMベースのコンポーネントテスト

jsdom または happy-dom と Testing Library を使ったコンポーネントテスト。

レベル2がテストするもの

レベル2のテストは、シミュレートされたDOM環境でのコンポーネントの動作を検証します。実際のブラウザなしで、コンポーネントが正しい要素をレンダリングし、ユーザーイベントに応答し、状態を正しく更新することを確認できます。

典型的な対象:

  • コンポーネントのレンダリング(正しい要素を出力するか?)

  • 条件付き表示(propsや状態に基づいて表示/非表示するか?)

  • イベントハンドラー(クリックで正しい動作がトリガーされるか?)

  • propsに基づく動作

  • コンポーネント間の連携(親子コンポーネントの通信)

ツール

ツール役割
vitestテストランナー
jsdom または happy-domシミュレートされたブラウザDOM環境
@testing-library/reactDOMクエリとユーザーイベントシミュレーション
@testing-library/preactPreactプロジェクト向け

セットアップ

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 を使用してください。いずれにせよ jsdomhappy-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可観測性を、分離されたライブラリコードに対して持ちます。これは/verify-ui(英語版のスキルドキュメント: /verify-ui)の代替にはなりません -- あのスキルは、分離されたコンポーネントではなく実際のページに対するアサーションとなる、フルで動作しているアプリを検証するためのツールであり続けます。

レベル2を使用するタイミング

シナリオレベル2は適切か?
コンポーネントが誤ったテキストをレンダリングはい
propsが正しく渡されないはい
クリックハンドラーが状態を更新しないはい
要素が存在するが表示されないいいえ -- レベル5を使用
CSSレイアウトが壊れているいいえ -- レベル5を使用
コンポーネントに対する実際の算出スタイルを分離状態でアサートするはい -- Vitestブラウザモード経由(上記参照)
複数ページのナビゲーションフローいいえ -- レベル4を使用

Revision History

作成更新