絶対に落とせない導線を守るフロントエンドテストを1か月で構築した話
はじめに
こんにちは。カナリーでテクニカルリードエンジニアをしている @react_nextjs です。
私たちは 【もっといい「当たり前」をつくる】
をミッションに掲げている不動産テックカンパニーです。弊社では、現在下記のプロダクトを運用しています。
- 「CANARY」: BtoC のお部屋探しマーケットプレイス(アプリ/Web)
- 「CANARY Cloud」: BtoB SaaS(不動産の仲介会社様向けの顧客管理システム)
本記事では、わずか1か月で「絶対に落とせない」フロントエンドテスト基盤を構築したプロセスと具体策を共有します。
背景 / 課題
「CANARY」は、全国 47 都道府県の賃貸マンション・アパート情報を集約し、こだわり条件検索などの機能を備え、アプリ/Web 内での内見予約までを一気通貫で提供する「お部屋探し」サービスです。
このサービスは 2019 年のリリースを皮切りに多くの機能追加を重ねてきました。ソフトウェア開発においてバグは避けられないもので過去に発生したものには具体的に以下のものが挙げられます。
事例 | 影響 |
---|---|
コンポーネントの依存関係が複雑で、あるページを改修後、別ページのコンポーネントが突然消える | 必要な情報が表示されず、ユーザー体験が下がり離脱する |
問い合わせの JavaScript エラーで送信不可 | 内見予約の機会損失、売上げの減少 |
コンポーネントの複雑化も一因でしたが、テストが限定的だったためリグレッションを検知しきれないことが、根本的な問題でした。
プロダクトの理解から始める
やみくもに全ての箇所をテストするのは、短期間で成果を示すうえで現実的ではありません。そこで最初に行ったのは、プロダクトの価値を左右する絶対に壊せない導線を特定することでした。
-
ビジネス・ユーザー双方の痛点を洗い出す
- 売上直結 : お問い合わせができないと決済機会=売上が0になる
- 機会損失 : 検索結果が表示されない / 物件詳細が欠落するとユーザーが離脱
-
クリティカルユーザージャーニーを定義
- トップページ → 物件検索 → 物件詳細 → お問い合わせ完了
お問い合わせ導線を守ることを最優先に、クリティカルユーザージャーニーに絞ってテストを書くことが最短でインパクトを出すために必要だと定義しました。
テスト戦略の策定
期間が1か月ということもあり、クリティカルユーザージャーニーを確実に守るため、以下3つの軸で戦略を立てました。
- ツール選定
- スコープ
- テストスタイル
ツール選定
もともと一部で Jest を使った単体テストが散在していましたが、次の理由から Vitest への全面移行を決定しました。
- TypeScript をネイティブサポートしており、追加設定がほぼ不要
- Jest 比でテスト実行が高速
さらに、Vitestには
- ソースファイル内にテストを記述できる In‑Source Testing
- 実ブラウザで動作する Browser Mode(Experimental)
など多彩な機能があり、将来的なテスト手法の拡張余地も大きい点が決め手となりました。
スコープ
「CANARY」ではbulletproof-reactの設計に寄せて開発しています。
サンプル
src/features/inquiry/ # お問合せ機能
├── components/ # UIコンポーネント
│ ├── InquiryForm/ # お問い合わせ入力フォーム
│ │ └── InquiryForm.tsx
├── pages/ # ページコンポーネント
│ ├── InquiryPage/ # お問い合わせ入力ページ
│ │ ├── InquiryPage.tsx
│ │ ├── InquiryPage.stories.tsx
│ │ ├── InquiryPage.test.tsx
まずはfeatures/ドメイン/pages
にテストを導入しました。理由は次の2点です。
-
内部結合を一括で保証できる
- 主要コンポーネント(例 : InquiryForm)がページ直下に集約されているため、ページ単位のテストで結合も同時に保証できる。
- また、Kent C. Dodds 氏の Testing Trophy 記事を参考に、Integration Test を中心に据える方針を採用しています。
-
テスト効果が最大
- この1画面を守るだけで、お問い合わせ入力に関わるユーザーの振る舞いをほぼ網羅できる。
同じ方針で、検索結果ページや物件詳細ページなど他の主要フローへもテスト範囲を順次拡大しています。E2E テストも検討しましたが、テスト環境の準備コストが高いため初期フェーズでは見送りました。
テストスタイル
AHA Programming をテスト実装に活かす
AHAは Avoid Hasty Abstractions(性急な抽象化を避ける)の略で、Kent C. Dodds氏が提唱するテストコードの可読性と保守性を高めるための考え方です。
要は、早すぎる抽象化を避け、再利用性が明確になってから共通化するという考え方です。
CANARY では常に A/B テストが走っており、数週間で UI が大きく変わることも珍しくありません。この考え方を取り入れたことで、抽象化のタイミングに迷うことがなくなりました。
AAA(Arrange, Act, Assert)パターンを採用
AAA は、ユニットテストを分かりやすく、焦点を絞って書くための基本的な構造です。
- Arrange(準備):テスト対象のオブジェクトや必要な状態をセットアップします。
- Act(実行):テストしたい操作やメソッドを実行します。
- Assert(検証):期待する結果や状態を確認します。
InquiryPageでは、以下の4ケースを重視して AAA で実装しました。
- 入力フォームに値を入れ、確認画面へ遷移できる
- 利用規約リンクをクリックすると外部ページに遷移する
- 個人情報の取扱いリンクをクリックすると外部ページに遷移する
- 物件名クリックで詳細モーダルが開く
実際のお問合せ入力ページ
InquiryPage のテスト例
サンプル
const handlers = [mockApiHandler];
const setup = () => {
server.use(...handlers);
return render(<InquiryPage rooms={mockRooms} inquiry={mockInquiry} />);
};
describe('InquiryPage', () => {
beforeAll(() => {
Modal.setAppElement(document.createElement('div'));
});
it('入力フォームに値を入れ、お問合せ確認画面へ遷移できる ', async () => {
// Arrange(準備)
const { user } = setup();
// Act(動作)
const nameInput = screen.getByRole('textbox', { name: 'お名前 必須' });
const emailInput = screen.getByRole('textbox', { name: 'メールアドレス 必須' });
const phoneNumberInput = screen.getByRole('textbox', { name: '電話番号 必須' });
const lineIdInput = screen.getByRole('textbox', { name: 'LINE ID' });
const otherRequestInput = screen.getByRole('textbox', { name: 'ご要望・ご質問など' });
await user.type(nameInput, 'テスト太郎');
await user.type(emailInput, 'example@example.com');
await user.type(phoneNumberInput, '09012345678');
await user.type(lineIdInput, 'test_line_id');
await user.type(otherRequestInput, 'テストご要望');
await user.click(screen.getByRole('button', { name: '上記に同意して確認画面に進む' }));
// Assert(確認)
expect(mockRouter.asPath).toBe(`/testConfirm`);
});
it('利用規約リンクをクリックすると外部ページに遷移する', async () => {
// Arrange(準備)
const { user } = setup();
// Act(動作)
await user.click(screen.getByRole('link', { name: '利用規約' }));
// Assert(確認)
expect(link).toHaveAttribute(
'href',
expect.stringContaining('https://example.com'),
);
});
it('個人情報の取扱いリンクをクリックすると外部ページに遷移する', async () => {
// Arrange(準備)
const { user } = setup();
// Act(動作)
await user.click(screen.getByRole('link', { name: '個人情報の取り扱い' }));
// Assert(確認)
expect(link).toHaveAttribute(
'href',
expect.stringContaining('https://example.com'),
);
});
it('物件名クリックで詳細モーダルが開く', async () => {
// Arrange(準備)
const { user } = setup();
// Act(動作)
await user.click(screen.getByText('テスト物件'));
const modalHeading = await screen.findByRole('heading', {
level: 1,
name: 'テスト物件の賃貸',
});
// Assert(確認)
expect(modalHeading).toBeInTheDocument();
});
}
ユーザー体験に直結する操作に絞り、まずはハッピーパス(正常系)のみをテストすることで、バグが出ればユーザーが即離脱しかねない導線を最小限ながら確実にカバーしました。
この方針を横展開し、検索結果ページや物件詳細ページなど他の主要フローにも順次テスト範囲を拡充しています。
効果
- 即時的な効果(定性)
- 大規模リファクタリング中でも「CI が通っていればクリティカルユーザージャーニーは壊れていない」と確認でき、デグレによる手戻りが激減
- 「コンポーネントが消える/問い合わせが送れない」といった致命的バグはテストで弾けるため、PR レビューを設計やドメインロジックの議論に集中できるようになった
背景 / 課題 で挙げた「問い合わせフォームが壊れても気づけない」「UI 改修で別ページが消える」といった問題は、ページ単位の Integration Testでほぼ再現なく検知できるようになりました。
今後の展望
- アンハッピーパスを追加
- たとえば、お問合せ入力画面(InquiryPage)ではバリデーションエラーのテストが実装されていないのでシナリオを追加
- 他ページでも表示されないとユーザーが困る要素を洗い出し、負のシナリオを補完
- ブラウザ E2E の導入
- Playwrightを使用して「トップ → 検索 → 詳細 → 問い合わせ完了」を自動化
- モバイル/デスクトップ両方の主要ビューポートで定期実行
- 複雑ロジックの単体テスト
- ビジネスクリティカルな hooks/utils を重点カバー
- 指標の定量計測
- バグ件数を四半期ごとに追跡し ROI を可視化
おわりに
ハッピーパスに絞って「絶対に落とせない導線」を最短で守ることから着手した今回の取り組みは、わずか1か月でユーザーにもチームにも大きな安心感をもたらしました。
バックエンドにはテストがあっても、フロントエンドは手つかず……という現場は少なくありません。
もし同じ悩みをお持ちなら、クリティカルな画面を洗い出し、テストを書くというところから始めてみてはいかがでしょうか。
最後までお読みいただき、ありがとうございました!
Discussion