概要
チームもプロダクトも増えて、他の部署も私たちのチームが作ったモノレポを共通プラットフォームとして使うようになりました。これに伴い、共通コンポーネントのテスト方針を決めて、テストコードを導入し、共通コンポーネントを簡単かつ安全に修正できるようにしたいと考えました。
既に共通コンポーネントにはテストコードがありましたが、明確な方針なしに作成されていたため、内容がバラバラでした。今回方針を決めて、テストコードの作成基準を統一することにしました。
コンポーネントのテスト対象
Reactコンポーネントは Props、State、そしてPropsとStateによって画面に表示される View、表示されたViewとユーザーのインタラクションを担当する Event で構成されます。

ここでテスト対象は以下の2つです。
- View(Props、StateによるBusiness Logic)
Props, State => Business Logic => View - Event(Event Handler内のBusiness Logic)
Event => Business Logic => State => Business Logic => View Event => Business Logic => Parent Event Handler
テスト方針を決めて、これらの部分を効果的にテストできるようにする必要があります。
テスト方針
テスト対象に基づいて、以下のようなテスト方針を決めました。
- Propsを変更しながらViewを確認
- Props、State(Propsによって初期化されるState)変更 => Business Logic => View確認
- イベントを発生させながらViewまたはEvent Handlerを確認
- Event発火 => Business Logic => State => Business Logic => View確認
- Event発火 => Business Logic => Parent Event Handler確認
- アクセシビリティ(Accessibility)テスト
テスト方法
コンポーネントテストはJest、StorybookのInteractionテスト、Vitestで作成できます。まずそれぞれの方法を見て、チームに最適な方法を選択することにしました。
Jest
Jest + Puppeteerを使用したVRT環境構築の詳細については [React] Jest + PuppeteerでVRT構築 を参考にしてください。
1. Propsを変更しながらViewを確認
スナップショットテストを活用して、Props変更に伴うViewを検証します。
it('should match snapshot for primary variant', async () => {
await expectToMatchVRTSnapshot(
<Button variant="primary">Primary Button</Button>,
'button-primary'
);
});
2. イベントテスト
イベントを発生させ、それに伴う状態変化とView変更を検証します。
test('displays custom fetchErrorText', async () => {
const error = new Error('Network error');
mockFetchChildNodesFunction.mockRejectedValueOnce(error);
const customErrorText = {
title: 'カスタムエラー',
description: 'カスタム説明',
btnLabel: 'リトライ',
};
render(
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchErrorText={customErrorText}
fetchChildNodesFunction={mockFetchChildNodesFunction}
/>
);
const expandButton = screen.getAllByRole('button')[0];
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('カスタムエラー')).toBeInTheDocument();
expect(screen.getByText('カスタム説明')).toBeInTheDocument();
expect(screen.getByText('リトライ')).toBeInTheDocument();
});
});
3. アクセシビリティテスト(jest-axe)
test('has no accessibility violations', async () => {
const { container } = render(
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
sectionLabel="アクセシビリティテスト"
countLabel="${count}件"
fetchChildNodesFunction={mockFetchChildNodesFunction}
onRemove={mockOnRemove}
/>
);
const results = await axe(container, {
rules: {
'button-name': { enabled: false },
},
});
expect(results).toHaveNoViolations();
});
Storybook
Storybook Test Runnerを使用したVRT + アクセシビリティテスト環境構築の詳細については [React] Storybook Test RunnerでVRT + アクセシビリティテスト構築 を参考にしてください。
1. Propsを変更しながらViewを確認(Storybook Snapshot)
export const RenderingTopTreeNodes: Story = {
name: 'Rendering: ツリーノード表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
parameters: {
chromatic: { disableSnapshot: false },
},
};
2. イベントテスト(Storybook Interaction)
export const EventDisplayChildNodes: Story = {
name: 'Event: フェッチ後に子ノード表示',
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
fetchChildNodesFunction={createMockFetchFunction()}
/>
</div>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const expandButton = canvas.getAllByRole('button')[0];
await userEvent.click(expandButton);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(canvas.getByText('Child 1')).toBeInTheDocument();
expect(canvas.getByText('Child 2')).toBeInTheDocument();
},
};
3. アクセシビリティテスト(Storybook a11y)
export const A11yTest: Story = {
name: 'Accessibility: アクセシビリティテスト',
tags: ['a11y-test'],
render: () => (
<div style={{ height: '25rem' }}>
<ExpandableTreeList
topTreeNodes={sampleTopTreeNodes}
sectionLabel="アクセシビリティテスト"
countLabel="${count}件"
fetchChildNodesFunction={createMockFetchFunction()}
onRemove={fn()}
/>
</div>
),
parameters: {
chromatic: {
disableSnapshot: true,
},
a11y: {
config: {
rules: [
{
id: 'button-name',
enabled: false,
},
],
},
},
},
};
Vitest
Vitestを使用したVRT + アクセシビリティテスト環境構築の詳細については [React] VitestでコンポーネントVRT + アクセシビリティテスト環境構築 を参考にしてください。
1. Propsを変更しながらViewを確認
Vitestは基本的にVRT(Visual Regression Testing)をサポートしています。
it('renders with text content', async () => {
render(<Button>Click me</Button>);
await expect
.element(page.getByRole('button'))
.toMatchScreenshot('button-children-text.png');
});
2. イベントテスト
it('calls onClick handler when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
3. アクセシビリティテスト(axe-core)
it('should have no accessibility violations', async () => {
const { container } = render(<Button>Accessible Button</Button>);
const results = await axe.run(container, axeConfig);
expect(results.violations).toHaveLength(0);
});
比較と結論
| Jest | Storybook | Vitest | |
|---|---|---|---|
| Props変更に伴うView確認(VRT) | O(VRTテストは別ファイルで作成が必要) | O(プロダクトのテストコードと作成方法が異なる) | O |
| イベントテスト | O | O | O |
| アクセシビリティテスト | O | O | O |
結論:Vitestを選択した理由
3つの方法すべてでProps変更に伴うView確認、イベントテスト、アクセシビリティテストが可能でした。しかし、以下の理由からVitestが最も適切だと判断しました。
- Storybook: プロダクトのテストコード作成方法と異なり、Story内容が膨大になって管理が大変になります。Storybookはデザインや実装確認用途で活用するのが適切です。
- Jest: VRTテストは可能ですが、テストファイルを分ける必要がある制約があります。
- Vitest: 基本的にVRTを提供しており、プロダクトレベルのViewテストと同じ技術スタックを使用するため、最も適切です。
したがって、私たちのチームではVitestで共通コンポーネントのテストコードを作成することに決定しました。
参考: Vitest - Visual Regression Testing
まとめ
モノレポ環境でReact共通コンポーネントのテスト方針を決めるプロセスを共有しました。Props/StateによるViewテスト、イベントテスト、アクセシビリティテストをJest、Storybook、Vitestで比較し、最終的にVitestを選択した理由を共有しました。
今後はVitestで作成されたテストコードを通じて、他のチームも共通コンポーネントを簡単かつ安全に修正できるようになることを期待しています。また、テストコードを作成する基準が明確になり、一貫したテストコードが作成されるようになります。
既存のJestプロジェクトにVitestを段階的に導入する具体的な方法は[React] JestプロジェクトにVitestを導入する - Viewテスト分離戦略を、Vitest VRT+アクセシビリティテスト環境の構築については[React] VitestでコンポーネントVRT+アクセシビリティテスト環境構築を参考にしてください。
このようにテスト方針を決めると、生成AIを活用してテストコードも簡単に作成できます。皆さんもチームに合ったテスト方針を決めて、生成AIを活用してテストコードを簡単かつ迅速に作成してみてください!
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。