KAKEHASHI Tech Blog

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

Zustand を使用して状態管理のテストを独立して行う方法を考える

こんにちは、カケハシのAI在庫管理チームでフロントエンドエンジニアをしている江藤です。

AI在庫管理では複雑な依存関係を持ったフォームが数多く存在します。それらのコンポーネントの取りうる状態を網羅的にテストしようとすると組み合わせ数が膨大になり、React Testing Library(以下、RTL) を使う場合はモックの準備やレンダリングのコストが無視できないほど大きくなります。

また、レンダリング結果をアサーションする都合上、テストが失敗した際に状態管理ロジックがおかしいのか、コンポーネントの実装がおかしいのか切り分けが難しい場合があります。

今回はそのような場合に、AI在庫管理で検討している状態管理の部分のテストを分離する方法について考えてみます。

はじめに

AI在庫管理チームでは状態管理に Zustand を使用しています。Zustand 導入の経緯や活用法に関しては以下の記事をご覧ください。

今回考えるお題

今回は説明を簡潔にするため、以下のようなコンポーネントを例にします。

  • 1つの医薬品の出庫を行うフォームコンポーネントで出庫区分、出庫先、出庫数を入力できる。

主な仕様を下記のように定めます。

  • ① 出庫区分が変更された場合、出庫先と出庫数の入力はリセットされる。
  • ② 出庫区分が「法人内」の場合は総額のみ表示され、「法人間」の場合は総額と税額が表示される。
  • ③ 出庫区分が「法人間」の場合は出庫先によって、金額の計算方法が内税か外税か決まる。

このフォームの状態を管理する Zustand のストアは以下のようになりそうです。

type Kind = "法人内" | "法人間";

// 法人内出庫先
type InternalStore = {
    id: string;
    name: string;
};

// 法人間出庫先
type ExternalStore = {
    id: string;
    name: string;
    calculationMethod: "内税" | "外税";
};

type Medicine = {
    id: string;
    name: string;
    price: number;
};

type Store = {
    internalStores: InternalStore[];
    externalStores: ExternalStore[];
    medicine: Medicine,
    formValues: {
        kind: Kind;
        targetId: string;
        quantity: string;
    };
    actions: {
        selectKind: (kind: Kind) => void;
        selectTarget: (targetId: string | null) => void;
        inputQuantity: (quantity: string | null) => void;
        ...
    };
};

const createFormStore = (medicine: Medicine, internalStores: InternalStore[], externalStores: ExternalStore[]) => {
    return create<Store>()(...);
};

状態管理ロジックのみをテストする

①の仕様を実現するための実装としては以下のようになりそうです(実際のAI在庫管理のコードに倣って zustand/middleware/immer を使用しています)。

create<Store>()(
    immer((set) => ({
        ...,
        actions: {
            ...,
            selectKind: (kind) => {
                set((state) => {
                    if (state.kind === kind) return;
                    state.kind = kind;
                    // 出庫先と出庫数をリセット
                    state.targetId = "";
                    state.quantity = "";
                });
            }
        }
    }))
);

テストの事前状態の再現にはなるべく actions で定義したメソッドを使用します。Zustand のストアには setState というメソッドが用意されていますが、なるべく実際の操作と同様の手順を踏みたいので使用は最小限にします。

アサーションは getState というメソッドからその時点の状態を取得し、 selector を介して行います。こちらも実際の表示に合わせてアサーションしたいので、getState した値をそのままアサーションすることはほとんどないと思います。

selector については Redux の公式サイト https://redux.js.org/usage/deriving-data-selectors に記載されています。

// selector.ts
const selectActions = (state: Store) => state.actions;
const selectTargetId = (state: Store) => state.formValues.targetId;
const selectQuantity = (state: Store) => state.formValues.quantity;

// xxx.test.ts
test("①出庫区分が変更された場合、出庫先と出庫数の入力はリセットされること", () => {
    // ストアの作成
    const useStore = createFormStore(...);
    const { actions } = selectActions(useStore.getState());

    // 事前状態のセット
    actions.selectTarget("1");
    actions.inputQuantity("1");

    // 更新関数の実行
    actions.selectKind("法人間");

    // リセットされていることのアサーション
    expect(selectTargetId(useStore.getState())).toBe("");
    expect(selectQuantity(useStore.getState())).toBe("");
});

このように書くことで、React に依存しない高速なロジックテストが簡単に実現できます。

同様に②、③のテストも以下のように書くことができます。

test("②出庫区分が「法人内」の場合は総額のみ計算されること", () => {
    const medicine = {..., price: 100};
    const useStore = createFormStore(medicine, ...);
    const { actions } = selectActions(useStore.getState());

    actions.selectTarget("1");
    actions.inputQuantity("1");

    expect(selectTotalAmount(useStore.getState())).toBe(100);
    expect(selectTaxAmount(useStore.getState())).toBeNull();
});

test("②出庫区分が「法人間」の場合は総額と税額が計算されること", () => {
    const medicine = {..., price: 100};
    const externalStores = [{id: "1", name: "...", calculationMethod: "外税"}];
    const useStore = createFormStore(medicine, ..., externalStores);
    const { actions } = selectActions(useStore.getState());

    actions.selectKind("法人間");
    actions.selectTarget("1");
    actions.inputQuantity("1");

    expect(selectTotalAmount(useStore.getState())).toBe(110);
    expect(selectTaxAmount(useStore.getState())).toBe(10);
});

test("③出庫区分が「法人間」の場合は出庫先によって、金額の計算方法が内税か外税か決まること", () => {
    const medicine = {..., price: 100};
    const externalStores = [{id: "1", name: "...", calculationMethod: "外税"}, {id: "2", name: "...", calculationMethod: "内税"}];
    const useStore = createFormStore(medicine, ..., externalStores);
    const { actions } = selectActions(useStore.getState());

    actions.selectKind("法人間");
    actions.selectTarget("1");
    actions.inputQuantity("1");

    // 外税
    expect(selectTotalAmount(useStore.getState())).toBe(110);
    expect(selectTaxAmount(useStore.getState())).toBe(10);


    actions.selectTarget("2");

    // 内税
    expect(selectTotalAmount(useStore.getState())).toBe(100);
    expect(selectTaxAmount(useStore.getState())).toBe(9);
});

非同期処理や副作用のテスト

ここで扱う内容は状態管理の枠をやや超えますが、API 通信などの非同期処理や、その結果に伴う副作用(例: トースト表示)も同じ手順でテストできます。

この手法を最大限に活かすため、ビジネスロジックはコンポーネントではなく Zustand のストアに集約することを設計原則とします。具体的には次のとおりです。

  • 状態更新ロジックはすべて actions に集約する
  • 副作用(API 通信、トースト表示など)は依存として注入して実装する
  • コンポーネントは actions を呼び出すだけの薄いレイヤーに留める

こうすることで、ビジネスロジックは UI から分離され、単体でテスト可能になります。

例えば、登録処理でエラーが起きた際にトーストを表示する場合は次のように書けます。

type Store = {
    ...
    actions: {
        ...,
        // 副作用のある関数を引数で受け取る
        submit: (mutation: (payload: Payload) => Promise<MutationResponse>, showToast: () => void) => Promise<void>
    }
};

create<Store>()(
    (set, get) => ({
        ...,
        actions: {
            ...,
            submit: async (mutation, showToast) => {
                try {
                    await mutation(parsePayload(get()));
                    ...
                } catch (e) {
                    // エラー時にトーストを表示する
                    showToast();
                }
            }
        }
    })
);
test("登録エラー時にトーストが表示されること", async () => {
    const useStore = createFormStore(...);
    const { actions } = selectActions(useStore.getState());

    actions.selectTarget("1");
    actions.inputQuantity("1");

    const mutationMock = vi.fn().mockRejectedValue(new Error("mocked error"));
    const showToastMock = vi.fn();

    // 更新関数の実行
    await actions.submit(mutationMock, showToastMock);

    // トースト表示関数が呼ばれたことのアサーション
    expect(showToastMock).toHaveBeenCalledTimes(1);
});

このように副作用を伴う処理もシンプルにテストできます。

状態管理以外のテストが不要になるわけではない

Zustand のみで完結したテストコードでは、状態とそれを参照する selector が正しいことは確認できますが、その値をコンポーネントのあるべき場所に表示できているかは確認できません。仕様②に関しても項目などを含めた表示内容を確認したい場合は、RTL を使用するのがより適しているはずです。

今回紹介したようなテストは、あくまでも複雑な状態管理のパターンを網羅するためであることをしっかり理解しておく必要があります。

そして UI 部分のテストこそがユーザー体験に直結する部分でもあるので重要になってきます。RTL を用いた表示のテストにとどまらず、実際にブラウザを用いるテストやアクセシビリティに関するテストなど、まだまだ取り組める領域が多く存在します。

まとめ

複雑なフォームのテストにおいて、状態管理ロジックとUIのテストを分離するアプローチを紹介しました。

このアプローチがもたらす効果

  1. テストの高速化とコスト削減

    • 状態管理ロジックを純粋なJavaScriptテストとして実行
    • 複雑な状態遷移を網羅的にテストしても、実行時間への影響は最小限
  2. 責務の明確な分離

    • 状態管理テスト:ビジネスロジックの正確性を保証
    • UIテスト:ユーザー体験(表示、操作性、アクセシビリティ)に集中
    • バグ発生時の原因切り分けが容易に
  3. それぞれのテストの役割を最適化

このアプローチは状態管理テストを「置き換える」のではなく「分離する」ことが目的です。むしろ、責務を明確に分けることで、UIテストはより本質的な部分 ー 実際のユーザー体験の検証 ー に注力できるようになります。

冒頭で挙げた「テストコストの増大」や「バグの原因切り分けの難しさ」といった課題は、この責務分離によって解決できるはずです。


株式会社カケハシでは現在採用強化中です。以下では、この度の採用に掛ける弊社の意気込みをまとめています。ぜひ読んでみてください!