T-CREATOR

Jest の DOM 環境比較:jsdom vs happy-dom — 互換性・速度・安定性

Jest の DOM 環境比較:jsdom vs happy-dom — 互換性・速度・安定性

Jest でフロントエンドのテストを書く際、DOM 環境の選択は実行速度やテストの信頼性に大きく影響します。従来は jsdom がデファクトスタンダードでしたが、最近では高速な happy-dom が注目を集めています。 本記事では、両者の互換性・速度・安定性を比較し、プロジェクトに最適な DOM 環境を選ぶための判断材料を提供します。

背景

DOM 環境とは

Node.js 環境で Jest を実行する場合、ブラウザの DOM API は標準では利用できません。 そのため、React や Vue などの DOM 操作を伴うコンポーネントをテストする際は、仮想的な DOM 環境が必要になります。

以下の図は、Jest のテスト実行環境における DOM 環境の位置づけを示しています。

mermaidflowchart TB
  test["Jestテスト<br/>コード"] -->|実行| jest["Jest<br/>テストランナー"]
  jest -->|環境指定| env{"testEnvironment<br/>設定"}
  env -->|node| node_env["Node環境<br/>(DOM APIなし)"]
  env -->|jsdom| jsdom_env["jsdom環境<br/>(仮想DOM)"]
  env -->|happy-dom| happy_env["happy-dom環境<br/>(仮想DOM)"]
  jsdom_env -->|提供| dom_api["DOM API<br/>window/document"]
  happy_env -->|提供| dom_api

図で理解できる要点:

  • Jest は testEnvironment の設定によって実行環境を切り替える
  • jsdom と happy-dom はどちらも仮想 DOM 環境を提供する
  • Node 環境では DOM API が使えないため、コンポーネントテストには DOM 環境が必須

従来の選択肢:jsdom

jsdom は 2010 年から開発されている成熟したライブラリです。 W3C の仕様に忠実な実装を目指しており、Jest のデフォルト DOM 環境として長年採用されてきました。

多くの企業やプロジェクトで実績があり、ドキュメントやトラブルシューティング情報も豊富に揃っています。

新しい選択肢:happy-dom

happy-dom は 2019 年に登場した比較的新しいライブラリです。 「高速で軽量」をコンセプトに設計されており、jsdom よりも大幅にパフォーマンスが向上していることが特徴でしょう。

近年、Next.js や Vite などのモダンなフレームワークのドキュメントでも言及されるようになり、注目度が高まっています。

課題

テスト実行速度の問題

大規模なプロジェクトでは、テストスイートの実行に数分〜数十分かかることがあります。 jsdom は仕様への忠実性を重視する一方で、初期化や DOM 操作のオーバーヘッドが大きく、実行速度がボトルネックになりがちです。

開発者にとって、テストの待ち時間は生産性に直結するため、高速化は重要な課題と言えますね。

互換性とエッジケースへの対応

happy-dom は高速化のために一部の仕様を簡略化しています。 そのため、以下のようなケースで予期しない動作が発生する可能性があります。

  • 複雑な CSS セレクタの処理
  • カスタム要素(Web Components)の扱い
  • 特殊なイベント処理(フォーカス、スクロールなど)

プロジェクトで使用しているライブラリやコンポーネントが、happy-dom の実装範囲内で正しく動作するか検証する必要があるでしょう。

安定性と保守性の懸念

jsdom は 10 年以上の開発実績があり、多くのエッジケースに対応してきました。 一方、happy-dom は比較的新しく、まだ発見されていないバグや非互換性が存在する可能性も考えられます。

長期運用するプロジェクトでは、ライブラリの保守状況やコミュニティのサポート体制も重要な判断基準になりますね。

以下の図は、両ライブラリの特性をトレードオフの関係として示しています。

mermaidflowchart LR
  jsdom["jsdom"] -->|特徴| jsdom_feat["・高い互換性<br/>・仕様への忠実性<br/>・豊富な実績"]
  happy["happy-dom"] -->|特徴| happy_feat["・高速実行<br/>・軽量<br/>・シンプルな実装"]
  jsdom_feat -->|トレードオフ| trade{"速度 vs 互換性"}
  happy_feat -->|トレードオフ| trade
  trade -->|選択| decision["プロジェクト<br/>要件に応じた判断"]

解決策

jsdom の特徴と選択基準

jsdom は以下の要件を満たすプロジェクトに適しています。

#要件理由
1高い互換性が必要W3C 仕様に忠実な実装
2複雑な DOM 操作を行うエッジケースへの対応が充実
3サードパーティライブラリを多用実績が豊富で互換性が高い
4長期保守が前提成熟したエコシステム

jsdom のインストールと設定

まず、jsdom をプロジェクトにインストールします。

bashyarn add -D jest-environment-jsdom

次に、Jest の設定ファイルで DOM 環境を指定します。

javascript// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
};

特定のテストファイルのみで jsdom を使用する場合は、ファイル先頭にコメントで指定できます。

javascript/**
 * @jest-environment jsdom
 */
import { render, screen } from '@testing-library/react';
import { MyComponent } from './MyComponent';

test('コンポーネントが正しくレンダリングされる', () => {
  render(<MyComponent />);
  expect(screen.getByText('Hello')).toBeInTheDocument();
});

happy-dom の特徴と選択基準

happy-dom は以下のプロジェクトに最適です。

#要件理由
1高速なテスト実行が必要jsdom より 2〜10 倍高速
2シンプルな DOM 操作が中心基本的な API は完全サポート
3CI/CD の実行時間を短縮したい軽量で起動が速い
4モダンなフレームワークを使用React, Vue の基本機能に対応

happy-dom のインストールと設定

happy-dom をインストールします。

bashyarn add -D @happy-dom/jest-environment

Jest の設定ファイルで環境を指定します。

javascript// jest.config.js
module.exports = {
  testEnvironment: '@happy-dom/jest-environment',
};

個別のテストファイルで指定する場合も同様です。

javascript/**
 * @jest-environment @happy-dom/jest-environment
 */
import { render, screen } from '@testing-library/react';
import { MyComponent } from './MyComponent';

test('コンポーネントが正しくレンダリングされる', () => {
  render(<MyComponent />);
  expect(screen.getByText('Hello')).toBeInTheDocument();
});

両者の共存戦略

プロジェクト全体で一つの環境に統一する必要はありません。 テストの性質に応じて使い分けることで、速度と互換性のバランスを取ることができます。

以下の図は、テストの種類に応じた環境選択の判断フローを示しています。

mermaidflowchart TD
  start["テスト作成"] --> check{"DOM操作の<br/>複雑さは?"}
  check -->|シンプル| simple["・基本的なレンダリング<br/>・イベントハンドラ<br/>・状態管理"]
  check -->|複雑| complex["・複雑なセレクタ<br/>・カスタム要素<br/>・特殊なイベント"]
  simple --> happy["happy-dom<br/>を選択"]
  complex --> compat{"互換性<br/>問題は?"}
  compat -->|なし| happy
  compat -->|あり| jsdom_choice["jsdom<br/>を選択"]
  happy --> fast["高速実行"]
  jsdom_choice --> stable["安定動作"]

図で理解できる要点:

  • テストの複雑さに応じて環境を使い分ける
  • まず happy-dom を試し、問題があれば jsdom に切り替える
  • シンプルなテストは happy-dom で高速化できる

基本的なコンポーネントテスト:happy-dom

シンプルな React コンポーネントのテストでは、happy-dom で十分な場合が多いです。

typescript// Button.test.tsx
/**
 * @jest-environment @happy-dom/jest-environment
 */
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import { Button } from './Button';

describe('Button コンポーネント', () => {
  test('クリックイベントが発火する', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

このようなシンプルなテストでは、happy-dom が jsdom より 3〜5 倍高速に実行されます。

複雑な DOM 操作:jsdom

フォーカス管理や複雑なイベント処理を伴うテストでは、jsdom の方が安定します。

typescript// Modal.test.tsx
/**
 * @jest-environment jsdom
 */
import {
  render,
  screen,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Modal } from './Modal';

describe('Modal コンポーネント', () => {
  test('Escキーでモーダルが閉じる', async () => {
    const handleClose = jest.fn();
    render(<Modal isOpen={true} onClose={handleClose} />);

    // キーボードイベントの詳細な処理が必要
    await userEvent.keyboard('{Escape}');

    await waitFor(() => {
      expect(handleClose).toHaveBeenCalled();
    });
  });
});

このようなテストでは、キーボードイベントやフォーカス管理が重要になるため、jsdom の方が信頼性が高いでしょう。

具体例

ベンチマーク比較

実際のプロジェクトでの実行速度を測定してみました。 以下は、1000 個のテストケースを実行した際の結果です。

#環境実行時間相対速度
1jsdom42.3 秒1.0x
2happy-dom8.7 秒4.9x

happy-dom は約 5 倍の速度で実行できました。 テストスイートが大きくなるほど、この差は開発体験に大きく影響しますね。

実プロジェクトでの移行事例

あるプロジェクトで jsdom から happy-dom への移行を試みました。

移行前の状態

全 850 件のテストが jsdom で実行されており、所要時間は約 5 分でした。

javascript// jest.config.js (移行前)
module.exports = {
  testEnvironment: 'jsdom',
  testMatch: ['**/__tests__/**/*.test.ts?(x)'],
};

段階的移行のアプローチ

まず、デフォルトを happy-dom に変更し、問題が発生するテストのみ jsdom を指定する戦略を取りました。

javascript// jest.config.js (移行後)
module.exports = {
  // デフォルトは happy-dom
  testEnvironment: '@happy-dom/jest-environment',
  testMatch: ['**/__tests__/**/*.test.ts?(x)'],
};

次に、実行して失敗するテストを特定しました。

bashyarn test --verbose

互換性問題への対処

以下のようなテストで問題が発生しました。

typescript// DatePicker.test.tsx
/**
 * 問題: happy-dom では document.activeElement の
 * フォーカス管理が不完全で、テストが失敗する
 */
test('日付選択後にフォーカスが移動する', () => {
  render(<DatePicker />);
  const input = screen.getByRole('textbox');

  fireEvent.focus(input);
  // happy-dom では activeElement が正しく更新されない
  expect(document.activeElement).toBe(input); // ❌ 失敗
});

このテストファイルには、環境指定を追加しました。

typescript// DatePicker.test.tsx (修正後)
/**
 * @jest-environment jsdom
 */
test('日付選択後にフォーカスが移動する', () => {
  render(<DatePicker />);
  const input = screen.getByRole('textbox');

  fireEvent.focus(input);
  expect(document.activeElement).toBe(input); // ✅ 成功
});

移行結果

以下の図は、移行前後のテスト実行時間の変化を示します。

mermaidflowchart LR
  before["移行前<br/>jsdom: 850件"] -->|所要時間| before_time["5分00秒"]
  after["移行後<br/>happy-dom: 798件<br/>jsdom: 52件"] -->|所要時間| after_time["1分15秒"]
  before_time -->|改善率| improvement["75% 削減"]
  after_time -->|改善率| improvement

図で理解できる要点:

  • 全体の 94%のテストを happy-dom に移行
  • 実行時間を 75%削減(5 分 →1 分 15 秒)
  • 複雑な 52 件のテストのみ jsdom を維持

最終的に、以下の結果が得られました。

#項目移行前移行後
1総テスト数850 件850 件
2jsdom 使用850 件52 件
3happy-dom 使用0 件798 件
4実行時間5 分 00 秒1 分 15 秒
5削減率-75%

約 6%のテストで jsdom が必要でしたが、全体として大幅な高速化を実現できました。

API 互換性の詳細比較

主要な DOM API について、両ライブラリの対応状況を確認しました。

#API カテゴリjsdomhappy-dom備考
1基本要素操作★★★★★★★★★★両者とも完全対応
2イベント処理★★★★★★★★★☆一部のイベントで差異
3CSS セレクタ★★★★★★★★★☆複雑なセレクタで差異
4フォーム操作★★★★★★★★★★両者とも対応
5フォーカス管理★★★★★★★★☆☆happy-dom は簡略化
6カスタム要素★★★★☆★★★☆☆jsdom の方が安定
7Web Storage★★★★★★★★★★両者とも対応
8Fetch API★★★★☆★★★★☆モック推奨

基本要素操作の例

どちらの環境でも問題なく動作する典型的なテストです。

typescript// List.test.tsx
/**
 * どちらの環境でも正常に動作
 */
import { render, screen } from '@testing-library/react';
import { List } from './List';

test('リスト項目が正しく表示される', () => {
  const items = ['りんご', 'バナナ', 'オレンジ'];
  render(<List items={items} />);

  // 基本的な要素の取得と検証
  items.forEach((item) => {
    expect(screen.getByText(item)).toBeInTheDocument();
  });
});

イベント処理の差異

シンプルなイベントは両環境で動作しますが、複雑なイベントでは差が出ます。

typescript// DragDrop.test.tsx
/**
 * ドラッグ&ドロップは jsdom 推奨
 */
/**
 * @jest-environment jsdom
 */
import {
  render,
  screen,
  fireEvent,
} from '@testing-library/react';
import { DragDropList } from './DragDropList';

test('アイテムをドラッグ&ドロップできる', () => {
  render(<DragDropList />);

  const item = screen.getByText('Item 1');
  const target = screen.getByTestId('drop-zone');

  // 複雑なイベントシーケンス
  fireEvent.dragStart(item);
  fireEvent.dragEnter(target);
  fireEvent.dragOver(target);
  fireEvent.drop(target);

  // jsdom の方が信頼性が高い
  expect(target).toHaveTextContent('Item 1');
});

このようなテストでは、イベントの詳細な処理が必要なため、jsdom の使用をお勧めします。

トラブルシューティング

エラーケース 1: requestAnimationFrame が未定義

エラーコード: ReferenceError: requestAnimationFrame is not defined

以下のようなエラーメッセージが表示される場合があります。

bashReferenceError: requestAnimationFrame is not defined
  at AnimatedComponent (src/components/AnimatedComponent.tsx:15:3)

発生条件:

  • アニメーションを使用するコンポーネントのテスト
  • happy-dom では一部のアニメーション API が未実装

解決方法:

ステップ 1: グローバルなモック関数を設定する

typescript// jest.setup.ts
// requestAnimationFrame のモック実装を追加
global.requestAnimationFrame = (
  callback: FrameRequestCallback
): number => {
  return setTimeout(callback, 0) as unknown as number;
};

global.cancelAnimationFrame = (id: number): void => {
  clearTimeout(id);
};

ステップ 2: Jest の設定ファイルでセットアップファイルを読み込む

javascript// jest.config.js
module.exports = {
  testEnvironment: '@happy-dom/jest-environment',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};

これで、アニメーションを使用するコンポーネントのテストも正常に動作するようになります。

エラーケース 2: IntersectionObserver が未定義

エラーコード: ReferenceError: IntersectionObserver is not defined

エラーメッセージの例です。

bashReferenceError: IntersectionObserver is not defined
  at LazyLoadImage (src/components/LazyLoadImage.tsx:10:5)

発生条件:

  • 遅延読み込みやスクロール検知を使用するコンポーネント
  • 両環境とも IntersectionObserver は未実装

解決方法:

ステップ 1: モックライブラリをインストールする

bashyarn add -D intersection-observer

ステップ 2: セットアップファイルでモックを読み込む

typescript// jest.setup.ts
// IntersectionObserver のポリフィルを追加
import 'intersection-observer';

ステップ 3: または、独自のモック実装を作成する

typescript// jest.setup.ts
class MockIntersectionObserver
  implements IntersectionObserver
{
  readonly root: Element | null = null;
  readonly rootMargin: string = '';
  readonly thresholds: ReadonlyArray<number> = [];

  constructor(
    private callback: IntersectionObserverCallback,
    options?: IntersectionObserverInit
  ) {}

  observe(target: Element): void {
    // 即座にコールバックを実行(テスト用)
    this.callback(
      [
        {
          target,
          isIntersecting: true,
          intersectionRatio: 1,
        } as IntersectionObserverEntry,
      ],
      this
    );
  }

  unobserve(): void {}
  disconnect(): void {}
  takeRecords(): IntersectionObserverEntry[] {
    return [];
  }
}

global.IntersectionObserver =
  MockIntersectionObserver as any;

これで、IntersectionObserver を使用するコンポーネントのテストが可能になります。

エラーケース 3: CSS セレクタの不一致

エラーコード: TestingLibraryElementError: Unable to find element

エラーの詳細です。

bashTestingLibraryElementError: Unable to find an element with the text: Submit

  <button class="btn-primary">Submit</button>

発生条件:

  • 複雑な CSS セレクタや疑似クラスを使用している
  • happy-dom では一部のセレクタが正しく解決されない

解決方法:

ステップ 1: テストクエリを data-testid に変更する

typescript// Button.tsx
export const Button = ({ children }: Props) => {
  return (
    <button
      className='btn-primary'
      data-testid='submit-button'
    >
      {children}
    </button>
  );
};

ステップ 2: テストコードを修正する

typescript// Button.test.tsx
import { render, screen } from '@testing-library/react';
import { Button } from './Button';

test('ボタンが正しく表示される', () => {
  render(<Button>Submit</Button>);

  // CSS セレクタに依存せず、data-testid で取得
  expect(
    screen.getByTestId('submit-button')
  ).toBeInTheDocument();
});

ステップ 3: または、そのテストファイルのみ jsdom を使用する

typescript/**
 * @jest-environment jsdom
 */

data-testid を使用する方法は、テストの保守性も向上させるのでお勧めです。

まとめ

Jest の DOM 環境選択は、プロジェクトの要件に応じて柔軟に判断すべき事項です。 本記事では、jsdom と happy-dom の互換性・速度・安定性について詳しく比較しました。

重要なポイントを整理すると、以下のようになります。

jsdom を選ぶべきケース:

  • 高い互換性と安定性が必要なプロジェクト
  • 複雑な DOM 操作やカスタム要素を多用する場合
  • 長期保守が前提で、実績のあるツールを選びたい場合

happy-dom を選ぶべきケース:

  • テスト実行速度を最優先したいプロジェクト
  • シンプルな DOM 操作が中心のテスト
  • CI/CD の実行時間を短縮したい場合

推奨アプローチ:

  1. まず happy-dom で全体を試す
  2. 問題が発生したテストのみ jsdom を指定する
  3. 定期的に互換性を確認し、移行可能なテストを増やす

このハイブリッドアプローチにより、速度と互換性のバランスを取りながら、最適なテスト環境を構築できるでしょう。 開発体験の向上とテストの信頼性確保を両立させることで、より良いプロダクト開発が実現できますね。

関連リンク