Vitest Browser Modeを活用してブラウザをモックするコードを削除した話

  • URLをコピーしました!

こんにちは、フロントエンドエンジニアインターンの髙橋(RYU)です。

一部のテストを Vitest の Browser Mode で実行するようにしました。その経緯と理由、効果についてご紹介します。

目次

背景

これまで、フロントエンドのユニットテストには Vitest と jsdom を組み合わせて使用してきました。jsdom を利用することで、Node 環境下でもブラウザの DOM をエミュレーションでき、コンポーネントの単体テストなどが可能でした。

課題

しかし、Vitest と jsdom の組み合わせには以下の問題がありました。

  • ブラウザ固有の API が利用できない
    • jsdom 環境では、ブラウザ固有の API(例:Canvas API)をそのまま使用できないため、モックライブラリを導入してエミュレートする必要がありました。
  • モックライブラリの管理と設定の手間
    • モックライブラリを別途管理・設定する必要があり、環境構築や保守に負担がかかっていました。
    • 使用していたモックライブラリの例

解決策と理由

これらの課題を解決するために、Vitest の Browser Mode を採用しました。本ブログ執筆時点(2025/2/21)ではExperimentalな機能です。 

  • ブラウザ固有の機能がそのまま利用可能
    • Browser Mode では、テストが実際のブラウザ(Playwright 経由)上で実行されるため、ブラウザ固有の機能をそのまま使用できます。
  • モックライブラリが不要
    • Node 環境下で不足しているブラウザ向け機能を、モックに頼らず実現できるため、モックライブラリの管理が不要になります。

実際に行ったこと

Browser Mode の設定

  • テストファイルの拡張子の前に.browser.test を付与することで、Browser Mode で実行されるように設定しました。
  • VitestのWorkspace機能を使用し、既存の jsdom の設定と Browser Mode の設定を共存させています。

設定ファイルの例

import { defineConfig, defineWorkspace, mergeConfig } from 'vitest/config';

// 共通の設定
const sharedConfig = {
  ...defineConfig({
    test: {
      exclude: ['**/node_modules/**', '**/dist/**'],
      forceRerunTriggers: [],
      dangerouslyIgnoreUnhandledErrors: true,
    },
  }),
};

// jsdomで実行するテストの設定
const jsDomConfig = defineConfig({
  test: {
    name: 'jsdom',
    globals: true,
    environment: 'jsdom',
    // jsdom 環境で実行するテストファイルの指定
    include: ['src/**/*.test.ts'],
    exclude: ['src/**/*.browser.test.ts'],
    setupFiles: ['src/__tests__/setup.ts'],
    server: {
      deps: {
        inline: [],
      },
    },
  },
});

// Browser Modeで実行するテストの設定
const browserConfig = defineConfig({
  test: {
    name: 'browser',
    globals: true,
    // ブラウザ環境で実行するテストファイルの指定
    include: ['src/**/*.browser.test.ts'],
    browser: {
      enabled: true,
      provider: 'playwright', // 使用するプロバイダー(他には'webdriverio')
      instances: [
        {
          globals: true,
          browser: 'chromium', // 使用するブラウザ(他には'firefox', 'webkit' など)
          viewport: {
            width: 1280,
            height: 720,
          },
        },
      ],
    },
  },
});

export default defineWorkspace([
  // 共通設定と各環境の設定をマージ
  mergeConfig(sharedConfig, jsDomConfig),
  mergeConfig(sharedConfig, browserConfig),
]);

詳しい設定方法は公式ドキュメントをご覧ください。

テストコードの変更

1. モックの削除

jsdom-testing-mocks などのモックライブラリを削除しました。これにより、依存関係が減り、管理が容易になりました。

import {composeStories} from '@storybook/react';
import {render} from '@testing-library/react';
import {userEvent} from '@testing-library/user-event';
-import {mockResizeObserver} from 'jsdom-testing-mocks';
import * as stories from './pulldown.stories';

-// ResizeObserverをmockする
-mockResizeObserver();

-/**
- * NOTE: Radix-UIのSelectコンポーネントがtesting-libraryで開けないため以下を参考にsetup
- * https://github.com/radix-ui/primitives/issues/1822
- */
-class MockPointerEvent extends Event {
-  button: number;
-  ctrlKey: boolean;
-  pointerType: string;
-
-  constructor(type: string, props: PointerEventInit) {
-    super(type, props);
-    this.button = props.button || 0;
-    this.ctrlKey = props.ctrlKey || false;
-    this.pointerType = props.pointerType || 'mouse';
-  }
-}
-
-globalThis.PointerEvent = MockPointerEvent as unknown as typeof PointerEvent;
-globalThis.HTMLElement.prototype.scrollIntoView = vi.fn();
-globalThis.HTMLElement.prototype.releasePointerCapture = vi.fn();
-globalThis.HTMLElement.prototype.hasPointerCapture = vi.fn();

// composeStories関数を使い、pulldownコンポーネントのデフォルトストーリーを取得する
const {Default} = composeStories(stories);

describe('Pulldown', () => {
  it('defaultValueが表示されること', async () => {
    // コンポーネントに渡すオプションの配列
    const options = [
      {key: '商品サービス', value: '商品サービス'},
      {key: 'イベント', value: 'イベント'},
      {key: 'キャンペーン', value: 'キャンペーン'},
    ];

    // Defaultストーリーのコンポーネントをレンダリングする際に、optionsとdefaultValueを指定
    const {getByRole} = render(
      <Default options={options} defaultValue='商品サービス' />,
    );

    // role属性が'combobox'である要素が、defaultValueである「商品サービス」のテキストを含んでいるか確認
    expect(getByRole('combobox')).toHaveTextContent('商品サービス');
    // また、「イベント」と「キャンペーン」のテキストが含まれていないことを確認する
    expect(getByRole('combobox')).not.toHaveTextContent('イベント');
    expect(getByRole('combobox')).not.toHaveTextContent('キャンペーン');
  });

  // ... その他のテストケース

  });
});

2. userEvent の変更

以前は import { userEvent } from '@testing-library/user-event'; を使用していましたが、Browser Mode に合わせて import { userEvent } from '@vitest/browser'; に変更しました。

import {composeStories} from '@storybook/react';
import {render} from '@testing-library/react';
-import {userEvent} from '@testing-library/user-event';
+import {userEvent} from '@vitest/browser/context';
import * as stories from './pulldown.stories';

const {Default} = composeStories(stories);

describe('Pulldown', () => {
  it('defaultValueが表示されること', async () => {
    // コンポーネントに渡すオプションの配列
    const options = [
      {key: '商品サービス', value: '商品サービス'},
      {key: 'イベント', value: 'イベント'},
      {key: 'キャンペーン', value: 'キャンペーン'},
    ];

    // Defaultストーリーのコンポーネントをレンダリングする際に、optionsとdefaultValueを指定
    const {getByRole} = render(
      <Default options={options} defaultValue='商品サービス' />,
    );

    // role属性が'combobox'である要素が、defaultValueである「商品サービス」のテキストを含んでいるか確認
    expect(getByRole('combobox')).toHaveTextContent('商品サービス');
    // また、「イベント」と「キャンペーン」のテキストが含まれていないことを確認する
    expect(getByRole('combobox')).not.toHaveTextContent('イベント');
    expect(getByRole('combobox')).not.toHaveTextContent('キャンペーン');
  });

  // ... その他のテストケース

  });
});

3. レンダリングの考慮

Browser Mode では実際にレンダリングが行われるため、テスト環境の設定に変更が必要でした。

例えば、TanStack QueryQueryClientProvider をテストに追加する必要がありました。

import {suppressErrorOutput} from '@/__tests__/utils/suppress-error-output';
import {render, waitFor} from '@testing-library/react';
-import {composeStories} from '@storybook/react';
-import {mockResizeObserver} from 'jsdom-testing-mocks';
-import {getWorker} from 'msw-storybook-addon';
-import {updateClipNameHandler} from '@prtimes-msw/handlers/clipping';
-import * as stories from './clip-name.stories';
+import {userEvent} from '@vitest/browser/context';
+import {type ReactNode, useState} from 'react';
+import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
+import {RecoilRoot} from 'recoil';
+import {ClipName} from '.';
+import {updateArchivedClipData} from '@/__tests__/data/update-archived-clip-data';
+import {ApiContext} from '@/contexts/api-context';

-mockResizeObserver();
-
-const {
-  Hover,
-  ...
-} = composeStories(stories);

+// ClipNameコンポーネントのテスト用ラッパーコンポーネント
+function ClipNameTest({children}: {readonly children?: ReactNode}) {
+  // QueryClientをuseStateで初期化。再フェッチやリトライを無効にしてテスト実行時の不要な処理を防止
+  const [queryClient] = useState(
+    () =>
+      new QueryClient({
+        defaultOptions: {
+          queries: {
+            retry: false,
+            refetchOnMount: false,
+            refetchOnWindowFocus: false,
+          },
+        },
+      }),
+  );
+
+  // APIのupdateClip関数をモック化し、呼び出された際にupdateArchivedClipDataを返すように設定
+  const updateClip = vi
+    .fn()
+    .mockImplementation(async () => updateArchivedClipData);
+
+  // ApiContextで提供する値
+  const [provideValue] = useState(() => ({
+    clipApi: {
+      updateClip,
+    },
+  }));
+
+  return (
+    // ApiContext.Providerで子コンポーネントにAPIコンテキストの値
+    // @ts-expect-error (型エラーを一時的に無視)
+    <ApiContext.Provider value={provideValue}>
+      <RecoilRoot>
+        <QueryClientProvider client={queryClient}>
+          <ClipName name='クリップ名' clipId={1} />
+        </QueryClientProvider>
+      </RecoilRoot>
+    </ApiContext.Provider>
+  );
+}

describe('ClipName', () => {
  describe('正常系', () => {
    it('ホバーしたときにtooltipが表示されること', async () => {
-      const {container, findByRole} = render(<Hover />);
-      await Hover.play({canvasElement: container});
+      // ClipNameTestコンポーネントをレンダリングし必要な要素を取得
+      const {findByLabelText, findByRole} = render(<ClipNameTest />);
+      // 「クリップ名を変更」というラベルが付いた要素を取得
+      const labelElement = await findByLabelText('クリップ名を変更');
+      // ホバー操作をシミュレーション
+      await act(async () => {
+        await userEvent.hover(labelElement, {timeout: 3000});
+      });
      // ツールチップ要素が表示されているか検証
      expect(
        await findByRole('tooltip', {name: 'クリップ名を変更'}),
      ).toBeVisible();
    });

    // ... その他のテストケース

  });
});

まとめ

Vitest の Browser Mode を導入することで、jest-canvas-mockjsdom-testing-mocks などのモックライブラリが不要になり、テスト環境の構築・保守が大幅に簡素化されました。また、ブラウザ固有の機能を直接利用できるため、テストの信頼性も向上しました。

  • URLをコピーしました!

この記事を書いた人

目次