こんにちは、Sitecore XM Cloud 技術担当の永田です。

Sitecore XM Cloud のフロントエンド(Next.js+JSS)を開発していくうちに、コンポーネントや API を自動でテストしたくなりました。
そうすることでコードの変更や修正時のデグレを防ぎやすくなりますし、ビルド時やコミット時にも自動実行できるので楽になります。
そこで、まずはコンポーネントの単体テストを検討することにしました。
この記事では、JSS の標準コンポーネントであるRichTextコンポーネントの単体テストについて記載します。

前提情報

動作確認した環境

  • Next.js 14.2.18
  • TypeScript 5.4.0
  • JSS 22.3.1

単体テスト環境の構築

以下の手順で、テストツールのVitestを導入してください。

  1. フロントエンドのルートディレクトリ(headapps\nextjs-starter)で以下のコマンドを実行します
    npm install --save-dev vitest @testing-library/react @testing-library/dom @testing-library/jest-dom jsdom @vitejs/plugin-react vite-tsconfig-paths
  2. フロントエンドのルートディレクトリに以下の設定用ファイルを作成します
    • vitest.config.ts
      import { defineConfig } from "vitest/config";
      import react from "@vitejs/plugin-react";
      import tsconfigPaths from "vite-tsconfig-paths";
      
      export default defineConfig({
        test: {
          environment: "jsdom", // webアプリ用の設定
          setupFiles: "./vitest.setup.ts",
          globals: true, // jestのようにAPIをグローバルに使う場合の設定
        },
        plugins: [tsconfigPaths(), react()], // typescriptやreactのためのプラグイン
      });
    • vitest.setup.ts
      import "@testing-library/jest-dom";
  3. フロントエンドのtsconfig.jsontypes"vitest/globals"を追加します
      {
        ・
        ・
        ・
        "types": ["node", "vitest/globals"],
        ・
        ・
        ・
      }
  4. フロントエンドのpackage.jsonscriptsに以下を追加します
      "scripts": {
        ・
        ・
        ・
        "test": "vitest" // テスト用コマンド
      }
    

なお、jest は esm に対応するための設定が必要なので、Vitest にしています。

手順

共通手順

コンポーネントの単体テストは、以下の手順で実施します。

  1. フロントエンドのルートディレクトリ(headapps\nextjs-starter)に__tests__フォルダを作成します(既にある場合は不要)
  2. テストファイルとしてコンポーネントの名前.test.tsxを作成し、テストコードを記載します
  3. フロントエンドのルートディレクトリでnpm run testコマンドを実行します
  4. コンソールにテストの結果が出力されるので確認します

テスト結果は以下のように出力されます。

結果が OK だった時

結果が NG だった時

失敗したテストケースの数と、どのように失敗したかが表示されます。

RichText コンポーネントの単体テスト例

testsフォルダにRichText.test.tsxを作成して、以下のようなテストコードを記載します。

//テストコード例import { render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { Default, RichTextProps } from '../src/components/RichText';
import { Field } from '@sitecore-jss/sitecore-jss-nextjs';
import { describe, expect, it, vi } from 'vitest';

describe('RichTextコンポーネントのテスト', () => {
  // useRouterのモック
  // eslint-disable-next-line @typescript-eslint/no-require-imports
  vi.spyOn(require('next/router'), 'useRouter').mockImplementation(() => ({
    query: { q: ['Mock_Vitest'] },
  }));

  it('テキストのみの値をレンダリングできること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: '' },
      fields: { Text: { value: 'Sample text' } as Field<string> },
    };

    // render()でコンポーネントをレンダリングし、containerを取得することでHTMLの確認ができる
    const { container } = render(<Default {...props} />);
    expect(container.firstChild).toHaveClass('component rich-text');

    const text = screen.getByText('Sample text');
    expect(text).toBeInTheDocument();
  });

  it('HTMLタグの値をHTMLとしてレンダリングできること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: '' },
      fields: { Text: { value: '<strong>Bold text</strong>' } },
    };

    const { container } = render(<Default {...props} />);
    expect(container.firstChild).toHaveClass('component rich-text');

    const text = screen.getByText('Bold text');
    expect(text.outerHTML).toBe('<strong>Bold text</strong>');
  });

  it('空文字の値を空文字としてレンダリングできること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: '' },
      fields: { Text: { value: '' } },
    };

    const { container } = render(<Default {...props} />);
    expect(container).toHaveTextContent('');
    expect(container.firstChild).toHaveClass('component rich-text');
  });

  it('Textプロパティがない場合は空文字としてレンダリングできること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: '' },
      fields: {},
    } as unknown as RichTextProps; // RichTextのpropsの型と不一致なので変換する

    const { container } = render(<Default {...props} />);
    expect(container).toHaveTextContent('');
    expect(container.firstChild).toHaveClass('component rich-text');
  });

  it('fieldsプロパティがない場合はCSSクラスにis-empty-hintを持つHTMLをレンダリングできること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: '' },
    } as unknown as RichTextProps;

    const { container } = render(<Default {...props} />);
    expect(container.querySelector('.is-empty-hint')).toHaveTextContent('Rich text');
  });

  it('paramsプロパティがない場合はTypeErrorになること', () => {
    const props = {
      fields: { Text: { value: 'No params.' } },
      params: {},
    } as unknown as RichTextProps;

    // 例外の補足はアロー関数を使う
    expect(() => render(<Default {...props} />)).toThrow(TypeError);
  });

  it('RenderingIdentifierプロパティの値をHTMLのidとして設定できること', () => {
    const props = {
      params: { styles: '', RenderingIdentifier: 'test-id' },
      fields: { Text: { value: 'Sample text' } },
    };

    const { container } = render(<Default {...props} />);
    expect(container.firstChild).toHaveAttribute('id', 'test-id');
  });

  it('stylesプロパティの値をHTMLのCSSクラスとして設定できること', () => {
    const props = {
      params: { styles: 'custom-style', RenderingIdentifier: '' },
      fields: { Text: { value: 'Sample text' } },
    };

    const { container } = render(<Default {...props} />);
    expect(container.firstChild).toHaveClass('component rich-text custom-style');
  });
});

以下、コードについての補足です。

useRouter について

@sitecore-jss/sitecore-jss-nextjsのコンポーネントのうち、<RichText />は内部で useRouter を使っているようです。
また、テストの実行はサーバーサイドであるため、そのままでは router オブジェクトが null になるとのことです。(参考:Next/Router の Mock - Vitest)
そこで、以下のように useRouter をモックしています。

// eslint-disable-next-line @typescript-eslint/no-require-imports
vi.spyOn(require("next/router"), "useRouter").mockImplementation(() => ({
  query: { q: ["Mock_Vitest"] },
}));

props の型について

コンポーネントに渡す props は各 tsx ファイルで型定義してあるため、プロパティが不正な props のテストをする時は以下のように型アサーションを利用します。
なお、コンパイルエラーの回避のためにas unknownを経由しています。(参考:型アサーション「as」(type assertion))

it("Textプロパティがない場合は空文字としてレンダリングできること", () => {
  const props = {
    params: { styles: "", RenderingIdentifier: "" },
    fields: {},
  } as unknown as RichTextProps;

  const { container } = render(<Default {...props} />);
  expect(container).toHaveTextContent("");
  expect(container.firstChild).toHaveClass("component rich-text");
});

例外の確認について

例外をテストしたい場合はexpect()の中でアロー関数を使い、render()をします。(参考:Vitest 公式ドキュメント - expect)

it("paramsプロパティがない場合はTypeErrorになること", () => {
  const props = {
    fields: { Text: { value: "No params." } },
    params: {},
  } as unknown as RichTextProps;

  expect(() => render(<Default {...props} />)).toThrow(TypeError);
});

注意点

テストケースについて

本記事中のテストコードは、あくまでサンプルです。
実際にプロジェクトに取り入れる際は、改めてテストケースのご検討をお願いします。

また、Page builder での配置や編集のような操作はカバーしきれないので、別途テストが必要です。

参考文献

以上です。
ここまでお読みいただきありがとうございました。

また、イーストではXM Cloud用に作成した独自のコンポーネントをGitHubで公開中です!
以下のeast-xmcloud-extensionsのバナーからご覧ください。