こんにちは、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
を導入してください。
- フロントエンドのルートディレクトリ(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
- フロントエンドのルートディレクトリに以下の設定用ファイルを作成します
- 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";
- vitest.config.ts
- フロントエンドの
tsconfig.json
のtypes
に"vitest/globals"
を追加します{ ・ ・ ・ "types": ["node", "vitest/globals"], ・ ・ ・ }
- フロントエンドの
package.json
のscripts
に以下を追加します"scripts": { ・ ・ ・ "test": "vitest" // テスト用コマンド }
なお、jest は esm に対応するための設定が必要なので、Vitest にしています。
手順
共通手順
コンポーネントの単体テストは、以下の手順で実施します。
- フロントエンドのルートディレクトリ(headapps\nextjs-starter)に
__tests__
フォルダを作成します(既にある場合は不要) - テストファイルとして
コンポーネントの名前.test.tsx
を作成し、テストコードを記載します - フロントエンドのルートディレクトリで
npm run test
コマンドを実行します - コンソールにテストの結果が出力されるので確認します
テスト結果は以下のように出力されます。
結果が 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のバナーからご覧ください。