KAKEHASHI Tech Blog

カケハシのEngineer Teamによるブログです。

型とテストで守るカスタムイベント通信 - 実プロダクトでの実装事例

1. はじめに

こんにちは。生成AI研究開発チームでAIエージェントの開発をしているNokogiri(@nkgrnkgr)です。

薬局向け業務システムMusubiはAngular製のWebアプリケーションです。このMusubi上で動作するAIアシスタント機能をReactで開発することになりました。Angular製の親アプリケーション(Musubi)とReact製の子アプリケーション(AIアシスタント)という異なるフレームワークが共存する特殊なアーキテクチャとなったため、両者の通信にはカスタムイベントを採用しています。

この記事では、TypeScriptの型システムを活用してカスタムイベント通信を安全に実装し、テスト可能にした方法を紹介します。実装の中で工夫した点やハマったポイントを共有することで、同じようにカスタムイベントを使おうとしている方の参考になれば幸いです。

2. 型定義で通信仕様を固める

2.1 イベント名の定数管理

Musubi側から発火されるカスタムイベントをAIアシスタント側で受け取るために、イベント名を定数として管理し、型として扱えるようにしました。

export const CUSTOM_EVENT_NAME = {
  MusubiContextChanged: "musubi_context_changed",
  MusubiUpdateIdToken: "musubi_update_id_token",
  MusubiSignOut: "musubi_sign_out",
  // ... 他のイベント
} as const;

// イベント名を型として扱う
export type CustomEventNameType = 
  (typeof CUSTOM_EVENT_NAME)[keyof typeof CUSTOM_EVENT_NAME];

as constを使うことで、文字列リテラル型として扱えるようになり、タイポを防げます。

2.2 ペイロードの型定義

Musubiには画面ごとにコンテキスト(薬局選択画面、患者選択画面、患者詳細画面など)があり、AIアシスタント側はこのコンテキストに応じて振る舞いを変える必要があります。そのため、コンテキストが変更されるたびにMusubiからイベントを受け取る設計にしました。

各イベントのペイロードは、ユニオン型を使って状態を表現しました。

export type MusubiContextChangedPayload =
  // ログアウト状態
  | {
      pharmacyId: null;
      patientId: null;
      idToken: null;
    }
  // 薬局コンテキスト(薬局は選択されているが患者は未選択)
  | {
      pharmacyId: string;
      patientId: null;
      idToken: string;
    }
  // 患者コンテキスト(薬局と患者の両方が選択されている)
  | {
      pharmacyId: string;
      patientId: string;
      idToken: string;
    };

nullを使って状態を表現することで、TypeScriptの型推論が効き、条件分岐で適切な型に絞り込まれます。

2.3 CustomEventMapによる型の紐付け

Mapped Typesを使って、イベント名とペイロードの型を紐付けました。

export type CustomEventMap = {
  [CUSTOM_EVENT_NAME.MusubiContextChanged]: MusubiContextChangedPayload;
  [CUSTOM_EVENT_NAME.MusubiUpdateIdToken]: MusubiUpdateIdTokenPayload;
  [CUSTOM_EVENT_NAME.MusubiSignOut]: unknown;
  // ... 他のイベント
};

これにより、イベント名からペイロードの型を自動的に推論できるようになります。開発者がコードを読む際に、どのイベントにどのようなデータ構造が紐づいているかが一目で理解できるようになりました。また、型定義を明確にすることで、MusubiとAIアシスタントで仕様の認識を揃えやすくなりました。

3. イベントハンドラーの実装パターン

3.1 イベントリスナー登録システム

イベントリスナー登録で工夫した点は、ZustandのStoreを外側から注入する設計にしたことです。カスタムイベントのハンドラー内で状態更新が発生することは明らかだったため、テスタビリティを上げる目的でこの設計を採用しました。

const CUSTOM_EVENT_MAP: Record<string, { 
  handle: (event: Event, store: Store) => void 
}> = {
  [CUSTOM_EVENT_NAME.MusubiContextChanged]: {
    handle: ContextChangedReceivedEvent.handle,
  },
};

export const createMusubiEventListenerRegister = () => {
  const register = (store: Store) => {
    for (const [eventName, value] of Object.entries(CUSTOM_EVENT_MAP)) {
      window.addEventListener(eventName, ((e: Event) => {
        if (e instanceof CustomEvent) {
          value.handle(e, store);
        }
      }) as EventListener);
    }
  };

  const unregister = (store: Store) => {
    // 同様にremoveEventListener
  };

  return { register, unregister };
};

この設計により、テスト時には専用のStoreインスタンスを渡して、イベント処理後の状態変化を外側から検証できるようになりました。

3.2 非同期処理を含むハンドラー

イベントハンドラーでは、同期処理と非同期処理を明確に分離しました。

export const ContextChangedReceivedEvent = {
  handle: async (event: Event, store: Store) => {
    const customEvent = event as CustomEvent<MusubiEvent<MusubiContextChangedPayload>>;
    const state = store.getState();
    const { prepareContextChange, logout, completeContextChange } = selectActions(state);

    const { pharmacyId, patientId, idToken } = customEvent.detail.payload;

    // 変更がない場合は早期リターン
    if (currentPharmacyId === pharmacyId && currentPatientId === patientId) {
      return;
    }

    // 同期的な状態更新(ローディング状態など)
    prepareContextChange();

    // 非同期処理(API呼び出しなど)
    try {
      const context = await buildContext(pharmacyId, patientId, idToken);
      completeContextChange(context);
    } catch (error) {
      logout(); // エラー時のフォールバック
    }
  },
};

3.3 エラーハンドリング

API通信失敗時などは、適切にフォールバック処理を行うようにしています。実際のプロダクトでは、エラーの種類に応じて処理を分けることもあります。

4. テスト戦略 - 正常系の実装

4.1 CustomEventのモック方法

CustomEventは直接newして生成することで、簡単にモックできました。

const event = new CustomEvent("contextChanged", {
  detail: {
    payload: {
      pharmacyId: "pharmacy-123",
      patientId: "patient-456",
      idToken: "token-789"
    }
  }
});

4.2 テストケースの設計

テストケースを型定義して管理することで、網羅的なテストを書きやすくしました。

type TestCase = {
  description: string;
  initialState: {
    isWindowOpen: boolean;
    licenseActivated: boolean;
    context: Context | null;
  };
  eventPayload: {
    pharmacyId: string | null;
    patientId: string | null;
    idToken: string | null;
  };
  expectedResult: {
    chatWindowShown: boolean;
    iconEnabled: boolean;
  };
};

4.3 非同期処理のテスト

最も工夫した点は、Storeを純粋関数的にテストする方法です。

const runTestCase = async (testCase: TestCase): Promise<TestResult> => {
  // 1. Storeに初期状態をセット(入力)
  const testStore = createAIChatStore();
  testStore.setState(testCase.initialState);

  // 2. イベントを作成して処理を実行
  const event = new CustomEvent("contextChanged", {
    detail: { payload: testCase.eventPayload }
  });
  await ContextChangedReceivedEvent.handle(event, testStore);

  // 3. 最終的なStore状態を検証(出力)
  const finalState = testStore.getState();
  return {
    finalChatWindowShown: selectChatWindowShown(finalState),
    finalIconEnabled: selectIconEnabled(finalState)
  };
};

このアプローチにより、副作用(API呼び出しなど)をモックしつつ、状態遷移のロジックを純粋にテストできます。入力(初期状態+イベント)と出力(最終状態)の関係だけに注目できるのが利点です。

5. 異常系テストで品質を担保

5.1 failure.test.tsの分離戦略

正常系と異常系でテストファイルを分けることで、可読性を向上させました。

  • index.test.ts: 正常系のテスト
  • index.failure.test.ts: 異常系のテスト

この分離により、それぞれのテストの意図が明確になり、メンテナンスもしやすくなりました。

5.2 エラーケースの網羅

異常系では以下のようなケースをテストしています:

const apiFailureTestCases: TestCase[] = [
  {
    description: "ライセンス認証API失敗時のログアウト処理",
    initialState: {
      isWindowOpen: true,
      licenseActivated: true,
      context: OLD_PHARMACY_CONTEXT,
    },
    eventPayload: {
      pharmacyId: NEW_PHARMACY_ID,
      patientId: null,
      idToken: NEW_TOKEN,
    },
  },
  {
    description: "処方箋フェッチAPI失敗時のログアウト処理",
    initialState: {
      isWindowOpen: true,
      licenseActivated: true,
      context: OLD_PHARMACY_CONTEXT,
    },
    eventPayload: {
      pharmacyId: OLD_PHARMACY_ID,
      patientId: NEW_PATIENT_ID,
      idToken: OLD_TOKEN,
    },
  },
];

// APIモックでエラーを発生させる
beforeEach(() => {
  const mockGetLicensesAuthorize = apis.getLicensesAuthorize as ReturnType<typeof vi.fn>;
  mockGetLicensesAuthorize.mockRejectedValue(new Error("License API failed"));
  
  const mockFetchPrescriptions = apis.fetchPrescriptions as ReturnType<typeof vi.fn>;
  mockFetchPrescriptions.mockRejectedValue(new Error("Prescription API failed"));
});

// テスト実行
it.each(apiFailureTestCases)("$description", async (testCase) => {
  const { finalChatWindowShown, finalIconEnabled } = await runLogoutTestCase(testCase);
  
  // 通信失敗時はログアウト処理と同じ結果になることを確認
  expect(finalChatWindowShown).toBe(false);
  expect(finalIconEnabled).toBe(false);
});

APIモックで意図的にエラーを発生させ、適切にフォールバック処理(この場合はログアウト)が動作することを確認しています。

6. 実装して学んだこと

6.1 型の恩恵を実感したポイント

型定義を明確にしたことで、どのようなイベントがどのような構造で飛んでくるかが一目でわかるようになり、MusubiチームとAIアシスタントチーム間での仕様の認識齟齬が起きにくくなりました。

また、新しいイベントを追加する際も、型定義を追加すれば自動的にコンパイルエラーで実装漏れを検知できるため、安心して開発を進められます。

6.2 テストを書きやすくする設計

イベントハンドラーを純粋関数的にテストできるようにしたことで、複雑な非同期処理も安心してテストできるようになりました。Storeの状態変化だけに注目すればよいため、テストの意図が明確になります。

6.3 チーム間の役割分担

実際にカスタムイベントのインターフェース部分を実装してみると、別リポジトリかつ別フレームワークであることから、チーム間のコミュニケーションが想像以上に大変でした。最終的には、Musubi側のイベント発火に関連するコードをAIアシスタントチームのメンバーが修正するという役割分担に落ち着きました。これにより、インターフェースの整合性を保ちやすくなり、開発効率が向上しました。

7. まとめ

TypeScriptの型システムを活用することで、カスタムイベント通信を安全かつテスタブルに実装できました。特に以下の点が重要でした:

  1. Mapped Typesによる型の紐付けで、イベント名とペイロードの関係を型レベルで保証
  2. Storeを介した純粋関数的なテストにより、複雑な非同期処理も安心してテスト可能に
  3. 型定義による仕様の明文化で、チーム間のコミュニケーションコストを削減

カスタムイベントは便利な反面、型情報が失われやすく、テストも書きにくいという課題があります。今回紹介した方法で、これらの課題を解決し、より安全で保守しやすいコードが書けるようになれば幸いです。

異なるフレームワーク間での通信や、複雑な状態管理を伴うイベント処理でお困りの方に、この実装事例が参考になることを願っています。