T-CREATOR

Jest で Redux のロジックをテストする手法

Jest で Redux のロジックをテストする手法

これらのテスト手法により、Redux の Action と非同期処理を包括的に検証し、アプリケーションの信頼性を確保できます。次のセクションでは、Selector のテスト実装について詳しく解説いたします。

Selector のテスト実装

Reselect を使った Selector のテスト

Selector は Redux State から必要なデータを取得・変換する重要な役割を果たします。特に Reselect ライブラリを使用した Selector は、メモ化による性能最適化が可能で、適切なテストが必要です。

javascript// src/selectors/userSelectors.js
import { createSelector } from 'reselect';

// 基本的な Selector
export const selectUserState = (state) => state.user;
export const selectCurrentUser = (state) =>
  state.user.currentUser;
export const selectUsers = (state) => state.user.users;
export const selectUserLoading = (state) =>
  state.user.loading;
export const selectUserError = (state) => state.user.error;

// メモ化された Selector
export const selectUserById = createSelector(
  [selectUsers, (state, userId) => userId],
  (users, userId) => {
    return users.find((user) => user.id === userId) || null;
  }
);

export const selectUserDisplayName = createSelector(
  [selectCurrentUser],
  (user) => {
    if (!user) return 'Guest';
    if (user.displayName) return user.displayName;
    if (user.firstName || user.lastName) {
      return `${user.firstName || ''} ${
        user.lastName || ''
      }`.trim();
    }
    return user.email || 'Unknown User';
  }
);

export const selectActiveUsers = createSelector(
  [selectUsers],
  (users) => {
    return users.filter(
      (user) => user.isActive && !user.isDeleted
    );
  }
);

// 複雑な計算を含む Selector
export const selectUserStatistics = createSelector(
  [selectUsers],
  (users) => {
    const activeUsers = users.filter(
      (user) => user.isActive
    );
    const inactiveUsers = users.filter(
      (user) => !user.isActive
    );

    return {
      total: users.length,
      active: activeUsers.length,
      inactive: inactiveUsers.length,
      activePercentage:
        users.length > 0
          ? Math.round(
              (activeUsers.length / users.length) * 100
            )
          : 0,
      averageAge:
        users.length > 0
          ? Math.round(
              users.reduce(
                (sum, user) => sum + (user.age || 0),
                0
              ) / users.length
            )
          : 0,
    };
  }
);

基本的な Selector のテスト:

javascript// src/selectors/__tests__/userSelectors.test.js
import {
  selectUserState,
  selectCurrentUser,
  selectUsers,
  selectUserById,
  selectUserDisplayName,
  selectActiveUsers,
  selectUserStatistics,
} from '../userSelectors';

describe('User Selectors', () => {
  const mockState = {
    user: {
      currentUser: {
        id: 1,
        firstName: 'John',
        lastName: 'Doe',
        email: 'john.doe@example.com',
        isActive: true,
        age: 30,
      },
      users: [
        {
          id: 1,
          firstName: 'John',
          lastName: 'Doe',
          email: 'john.doe@example.com',
          isActive: true,
          isDeleted: false,
          age: 30,
        },
        {
          id: 2,
          firstName: 'Jane',
          lastName: 'Smith',
          email: 'jane.smith@example.com',
          isActive: false,
          isDeleted: false,
          age: 25,
        },
        {
          id: 3,
          displayName: 'Admin User',
          email: 'admin@example.com',
          isActive: true,
          isDeleted: true,
          age: 35,
        },
      ],
      loading: false,
      error: null,
    },
    // 他の State 部分
    products: {
      items: [],
    },
  };

  describe('Basic Selectors', () => {
    test('selectUserState should return user state', () => {
      const result = selectUserState(mockState);
      expect(result).toBe(mockState.user);
    });

    test('selectCurrentUser should return current user', () => {
      const result = selectCurrentUser(mockState);
      expect(result).toBe(mockState.user.currentUser);
    });

    test('selectUsers should return users array', () => {
      const result = selectUsers(mockState);
      expect(result).toBe(mockState.user.users);
    });

    test('should handle missing user state gracefully', () => {
      const stateWithoutUser = { products: { items: [] } };

      expect(() =>
        selectUserState(stateWithoutUser)
      ).not.toThrow();
      expect(
        selectUserState(stateWithoutUser)
      ).toBeUndefined();
    });
  });

  describe('selectUserById', () => {
    test('should return user by id', () => {
      const result = selectUserById(mockState, 1);
      expect(result).toEqual(mockState.user.users[0]);
    });

    test('should return null for non-existent user', () => {
      const result = selectUserById(mockState, 999);
      expect(result).toBeNull();
    });

    test('should return null for invalid id', () => {
      const invalidIds = [
        null,
        undefined,
        '',
        'abc',
        0,
        -1,
      ];

      invalidIds.forEach((id) => {
        const result = selectUserById(mockState, id);
        expect(result).toBeNull();
      });
    });
  });

  describe('selectUserDisplayName', () => {
    test('should return display name when available', () => {
      const stateWithDisplayName = {
        ...mockState,
        user: {
          ...mockState.user,
          currentUser: {
            ...mockState.user.currentUser,
            displayName: 'Custom Display Name',
          },
        },
      };

      const result = selectUserDisplayName(
        stateWithDisplayName
      );
      expect(result).toBe('Custom Display Name');
    });

    test('should return full name when display name not available', () => {
      const result = selectUserDisplayName(mockState);
      expect(result).toBe('John Doe');
    });

    test('should return first name only when last name missing', () => {
      const stateWithFirstNameOnly = {
        ...mockState,
        user: {
          ...mockState.user,
          currentUser: {
            ...mockState.user.currentUser,
            firstName: 'John',
            lastName: null,
          },
        },
      };

      const result = selectUserDisplayName(
        stateWithFirstNameOnly
      );
      expect(result).toBe('John');
    });

    test('should return email when names not available', () => {
      const stateWithEmailOnly = {
        ...mockState,
        user: {
          ...mockState.user,
          currentUser: {
            email: 'user@example.com',
          },
        },
      };

      const result = selectUserDisplayName(
        stateWithEmailOnly
      );
      expect(result).toBe('user@example.com');
    });

    test('should return Guest when no current user', () => {
      const stateWithoutCurrentUser = {
        ...mockState,
        user: {
          ...mockState.user,
          currentUser: null,
        },
      };

      const result = selectUserDisplayName(
        stateWithoutCurrentUser
      );
      expect(result).toBe('Guest');
    });

    test('should return Unknown User as fallback', () => {
      const stateWithEmptyUser = {
        ...mockState,
        user: {
          ...mockState.user,
          currentUser: {},
        },
      };

      const result = selectUserDisplayName(
        stateWithEmptyUser
      );
      expect(result).toBe('Unknown User');
    });
  });

  describe('selectActiveUsers', () => {
    test('should return only active and non-deleted users', () => {
      const result = selectActiveUsers(mockState);

      expect(result).toHaveLength(1);
      expect(result[0]).toEqual(mockState.user.users[0]);
    });

    test('should return empty array when no active users', () => {
      const stateWithInactiveUsers = {
        ...mockState,
        user: {
          ...mockState.user,
          users: [
            {
              id: 1,
              name: 'Inactive User',
              isActive: false,
              isDeleted: false,
            },
            {
              id: 2,
              name: 'Deleted User',
              isActive: true,
              isDeleted: true,
            },
          ],
        },
      };

      const result = selectActiveUsers(
        stateWithInactiveUsers
      );
      expect(result).toEqual([]);
    });
  });

  describe('selectUserStatistics', () => {
    test('should calculate user statistics correctly', () => {
      const result = selectUserStatistics(mockState);

      expect(result).toEqual({
        total: 3,
        active: 2,
        inactive: 1,
        activePercentage: 67, // 2/3 * 100 = 66.67 -> 67
        averageAge: 30, // (30 + 25 + 35) / 3 = 30
      });
    });

    test('should handle empty users array', () => {
      const stateWithNoUsers = {
        ...mockState,
        user: {
          ...mockState.user,
          users: [],
        },
      };

      const result = selectUserStatistics(stateWithNoUsers);

      expect(result).toEqual({
        total: 0,
        active: 0,
        inactive: 0,
        activePercentage: 0,
        averageAge: 0,
      });
    });

    test('should handle users without age', () => {
      const stateWithUsersWithoutAge = {
        ...mockState,
        user: {
          ...mockState.user,
          users: [
            { id: 1, isActive: true },
            { id: 2, isActive: false, age: 25 },
          ],
        },
      };

      const result = selectUserStatistics(
        stateWithUsersWithoutAge
      );

      expect(result.averageAge).toBe(13); // (0 + 25) / 2 = 12.5 -> 13
    });
  });
});

メモ化の動作確認

Reselect の重要な機能であるメモ化が正しく動作することを確認するテスト:

javascript// src/selectors/__tests__/selectorMemoization.test.js
import {
  selectUserById,
  selectUserDisplayName,
  selectActiveUsers,
  selectUserStatistics,
} from '../userSelectors';

describe('Selector Memoization', () => {
  const baseState = {
    user: {
      currentUser: {
        id: 1,
        firstName: 'John',
        lastName: 'Doe',
      },
      users: [
        { id: 1, name: 'John', isActive: true, age: 30 },
        { id: 2, name: 'Jane', isActive: false, age: 25 },
      ],
    },
  };

  test('selectUserById should be memoized', () => {
    const userId = 1;

    // 最初の呼び出し
    const result1 = selectUserById(baseState, userId);

    // 同じ引数での2回目の呼び出し
    const result2 = selectUserById(baseState, userId);

    // 参照が同じであることを確認(メモ化されている)
    expect(result1).toBe(result2);
  });

  test('selectUserById should recalculate when users change', () => {
    const userId = 1;

    // 最初の呼び出し
    const result1 = selectUserById(baseState, userId);

    // users を変更した新しい state
    const newState = {
      ...baseState,
      user: {
        ...baseState.user,
        users: [
          {
            id: 1,
            name: 'John Updated',
            isActive: true,
            age: 31,
          },
          { id: 2, name: 'Jane', isActive: false, age: 25 },
        ],
      },
    };

    const result2 = selectUserById(newState, userId);

    // 結果が更新されていることを確認
    expect(result1).not.toBe(result2);
    expect(result2.name).toBe('John Updated');
    expect(result2.age).toBe(31);
  });

  test('selectUserStatistics should not recalculate when unrelated state changes', () => {
    // 最初の呼び出し
    const result1 = selectUserStatistics(baseState);

    // 関係のない部分を変更
    const stateWithUnrelatedChange = {
      ...baseState,
      products: {
        items: [{ id: 1, name: 'New Product' }],
      },
    };

    const result2 = selectUserStatistics(
      stateWithUnrelatedChange
    );

    // メモ化により同じ参照が返されることを確認
    expect(result1).toBe(result2);
  });

  test('selector should recalculate only when input changes', () => {
    let calculationCount = 0;

    // 計算回数をカウントするカスタム selector
    const selectUserStatisticsWithCounter = createSelector(
      [selectUsers],
      (users) => {
        calculationCount++;
        return {
          total: users.length,
          active: users.filter((user) => user.isActive)
            .length,
        };
      }
    );

    // 同じ state で複数回呼び出し
    selectUserStatisticsWithCounter(baseState);
    selectUserStatisticsWithCounter(baseState);
    selectUserStatisticsWithCounter(baseState);

    // 計算は1回だけ実行されるべき
    expect(calculationCount).toBe(1);

    // state を変更
    const newState = {
      ...baseState,
      user: {
        ...baseState.user,
        users: [
          ...baseState.user.users,
          { id: 3, name: 'Bob', isActive: true },
        ],
      },
    };

    selectUserStatisticsWithCounter(newState);

    // 新しい計算が実行される
    expect(calculationCount).toBe(2);
  });
});

複雑な計算ロジックを持つ Selector

より複雑なビジネスロジックを含む Selector のテスト例:

javascript// src/selectors/advancedUserSelectors.js
import { createSelector } from 'reselect';
import {
  selectUsers,
  selectCurrentUser,
} from './userSelectors';

// 複雑な検索・フィルタリング機能
export const selectFilteredUsers = createSelector(
  [selectUsers, (state, filters) => filters],
  (users, filters) => {
    if (!filters) return users;

    return users.filter((user) => {
      // 名前での検索
      if (filters.search) {
        const searchTerm = filters.search.toLowerCase();
        const fullName = `${user.firstName || ''} ${
          user.lastName || ''
        }`.toLowerCase();
        if (
          !fullName.includes(searchTerm) &&
          !user.email?.toLowerCase().includes(searchTerm)
        ) {
          return false;
        }
      }

      // 年齢範囲でのフィルタ
      if (filters.ageRange) {
        const { min, max } = filters.ageRange;
        if (user.age < min || user.age > max) {
          return false;
        }
      }

      // 役割でのフィルタ
      if (filters.roles && filters.roles.length > 0) {
        if (
          !user.roles ||
          !user.roles.some((role) =>
            filters.roles.includes(role)
          )
        ) {
          return false;
        }
      }

      // アクティブ状態でのフィルタ
      if (filters.isActive !== undefined) {
        if (user.isActive !== filters.isActive) {
          return false;
        }
      }

      return true;
    });
  }
);

// ページネーション機能
export const selectPaginatedUsers = createSelector(
  [
    selectFilteredUsers,
    (state, filters, pagination) => pagination,
  ],
  (filteredUsers, pagination) => {
    if (!pagination) return filteredUsers;

    const { page = 1, pageSize = 10 } = pagination;
    const startIndex = (page - 1) * pageSize;
    const endIndex = startIndex + pageSize;

    return {
      users: filteredUsers.slice(startIndex, endIndex),
      totalCount: filteredUsers.length,
      totalPages: Math.ceil(
        filteredUsers.length / pageSize
      ),
      currentPage: page,
      hasNextPage: endIndex < filteredUsers.length,
      hasPreviousPage: page > 1,
    };
  }
);

// ユーザー権限チェック
export const selectUserPermissions = createSelector(
  [selectCurrentUser],
  (currentUser) => {
    if (!currentUser || !currentUser.roles) {
      return {
        canViewUsers: false,
        canEditUsers: false,
        canDeleteUsers: false,
        canManageRoles: false,
      };
    }

    const roles = currentUser.roles;
    const isAdmin = roles.includes('admin');
    const isModerator = roles.includes('moderator');
    const isManager = roles.includes('manager');

    return {
      canViewUsers: true, // 全員が閲覧可能
      canEditUsers: isAdmin || isModerator || isManager,
      canDeleteUsers: isAdmin || isManager,
      canManageRoles: isAdmin,
      isAdmin,
      isModerator,
      isManager,
    };
  }
);

複雑な Selector のテスト:

javascript// src/selectors/__tests__/advancedUserSelectors.test.js
import {
  selectFilteredUsers,
  selectPaginatedUsers,
  selectUserPermissions,
} from '../advancedUserSelectors';

describe('Advanced User Selectors', () => {
  const mockUsers = [
    {
      id: 1,
      firstName: 'John',
      lastName: 'Doe',
      email: 'john.doe@example.com',
      age: 30,
      roles: ['user'],
      isActive: true,
    },
    {
      id: 2,
      firstName: 'Jane',
      lastName: 'Smith',
      email: 'jane.smith@example.com',
      age: 25,
      roles: ['user', 'moderator'],
      isActive: true,
    },
    {
      id: 3,
      firstName: 'Bob',
      lastName: 'Johnson',
      email: 'bob.johnson@example.com',
      age: 35,
      roles: ['admin'],
      isActive: false,
    },
    {
      id: 4,
      firstName: 'Alice',
      lastName: 'Brown',
      email: 'alice.brown@example.com',
      age: 28,
      roles: ['user', 'manager'],
      isActive: true,
    },
  ];

  const baseState = {
    user: {
      currentUser: null,
      users: mockUsers,
    },
  };

  describe('selectFilteredUsers', () => {
    test('should return all users when no filters applied', () => {
      const result = selectFilteredUsers(baseState, null);
      expect(result).toEqual(mockUsers);
    });

    test('should filter by search term', () => {
      const filters = { search: 'john' };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(2);
      expect(result).toEqual(
        expect.arrayContaining([
          expect.objectContaining({ firstName: 'John' }),
          expect.objectContaining({ lastName: 'Johnson' }),
        ])
      );
    });

    test('should filter by email search', () => {
      const filters = { search: 'jane.smith' };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(1);
      expect(result[0].email).toBe(
        'jane.smith@example.com'
      );
    });

    test('should filter by age range', () => {
      const filters = { ageRange: { min: 25, max: 30 } };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(3);
      expect(
        result.every(
          (user) => user.age >= 25 && user.age <= 30
        )
      ).toBe(true);
    });

    test('should filter by roles', () => {
      const filters = { roles: ['admin', 'moderator'] };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(2);
      expect(result).toEqual(
        expect.arrayContaining([
          expect.objectContaining({
            roles: expect.arrayContaining(['moderator']),
          }),
          expect.objectContaining({
            roles: expect.arrayContaining(['admin']),
          }),
        ])
      );
    });

    test('should filter by active status', () => {
      const filters = { isActive: true };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(3);
      expect(
        result.every((user) => user.isActive === true)
      ).toBe(true);
    });

    test('should apply multiple filters', () => {
      const filters = {
        search: 'john',
        ageRange: { min: 30, max: 40 },
        isActive: true,
      };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toHaveLength(1);
      expect(result[0].firstName).toBe('John');
    });

    test('should return empty array when no matches', () => {
      const filters = {
        search: 'nonexistent',
        ageRange: { min: 100, max: 200 },
      };
      const result = selectFilteredUsers(
        baseState,
        filters
      );

      expect(result).toEqual([]);
    });
  });

  describe('selectPaginatedUsers', () => {
    test('should return all users when no pagination', () => {
      const result = selectPaginatedUsers(
        baseState,
        null,
        null
      );
      expect(result).toEqual(mockUsers);
    });

    test('should paginate users correctly', () => {
      const pagination = { page: 1, pageSize: 2 };
      const result = selectPaginatedUsers(
        baseState,
        null,
        pagination
      );

      expect(result).toEqual({
        users: mockUsers.slice(0, 2),
        totalCount: 4,
        totalPages: 2,
        currentPage: 1,
        hasNextPage: true,
        hasPreviousPage: false,
      });
    });

    test('should handle second page', () => {
      const pagination = { page: 2, pageSize: 2 };
      const result = selectPaginatedUsers(
        baseState,
        null,
        pagination
      );

      expect(result).toEqual({
        users: mockUsers.slice(2, 4),
        totalCount: 4,
        totalPages: 2,
        currentPage: 2,
        hasNextPage: false,
        hasPreviousPage: true,
      });
    });

    test('should handle page beyond available data', () => {
      const pagination = { page: 10, pageSize: 2 };
      const result = selectPaginatedUsers(
        baseState,
        null,
        pagination
      );

      expect(result).toEqual({
        users: [],
        totalCount: 4,
        totalPages: 2,
        currentPage: 10,
        hasNextPage: false,
        hasPreviousPage: true,
      });
    });

    test('should work with filtered results', () => {
      const filters = { isActive: true };
      const pagination = { page: 1, pageSize: 2 };
      const result = selectPaginatedUsers(
        baseState,
        filters,
        pagination
      );

      expect(result.users).toHaveLength(2);
      expect(result.totalCount).toBe(3); // 3 active users
      expect(result.totalPages).toBe(2);
    });
  });

  describe('selectUserPermissions', () => {
    test('should return no permissions for null user', () => {
      const result = selectUserPermissions(baseState);

      expect(result).toEqual({
        canViewUsers: false,
        canEditUsers: false,
        canDeleteUsers: false,
        canManageRoles: false,
      });
    });

    test('should return basic permissions for regular user', () => {
      const stateWithUser = {
        ...baseState,
        user: {
          ...baseState.user,
          currentUser: {
            id: 1,
            roles: ['user'],
          },
        },
      };

      const result = selectUserPermissions(stateWithUser);

      expect(result).toEqual({
        canViewUsers: true,
        canEditUsers: false,
        canDeleteUsers: false,
        canManageRoles: false,
        isAdmin: false,
        isModerator: false,
        isManager: false,
      });
    });

    test('should return moderator permissions', () => {
      const stateWithModerator = {
        ...baseState,
        user: {
          ...baseState.user,
          currentUser: {
            id: 2,
            roles: ['user', 'moderator'],
          },
        },
      };

      const result = selectUserPermissions(
        stateWithModerator
      );

      expect(result).toEqual({
        canViewUsers: true,
        canEditUsers: true,
        canDeleteUsers: false,
        canManageRoles: false,
        isAdmin: false,
        isModerator: true,
        isManager: false,
      });
    });

    test('should return admin permissions', () => {
      const stateWithAdmin = {
        ...baseState,
        user: {
          ...baseState.user,
          currentUser: {
            id: 3,
            roles: ['admin'],
          },
        },
      };

      const result = selectUserPermissions(stateWithAdmin);

      expect(result).toEqual({
        canViewUsers: true,
        canEditUsers: true,
        canDeleteUsers: true,
        canManageRoles: true,
        isAdmin: true,
        isModerator: false,
        isManager: false,
      });
    });

    test('should handle user without roles', () => {
      const stateWithUserNoRoles = {
        ...baseState,
        user: {
          ...baseState.user,
          currentUser: {
            id: 1,
            // roles プロパティなし
          },
        },
      };

      const result = selectUserPermissions(
        stateWithUserNoRoles
      );

      expect(result).toEqual({
        canViewUsers: false,
        canEditUsers: false,
        canDeleteUsers: false,
        canManageRoles: false,
      });
    });
  });
});

Selector の依存関係テスト

Selector 間の依存関係が正しく動作することを確認するテスト:

javascript// src/selectors/__tests__/selectorDependencies.test.js
import {
  selectUsers,
  selectCurrentUser,
  selectUserById,
  selectActiveUsers,
  selectUserStatistics,
} from '../userSelectors';

describe('Selector Dependencies', () => {
  test('dependent selectors should update when base selectors change', () => {
    const initialState = {
      user: {
        users: [
          { id: 1, name: 'John', isActive: true, age: 30 },
          { id: 2, name: 'Jane', isActive: false, age: 25 },
        ],
      },
    };

    // 初期結果
    const initialActiveUsers =
      selectActiveUsers(initialState);
    const initialStats = selectUserStatistics(initialState);

    expect(initialActiveUsers).toHaveLength(1);
    expect(initialStats.active).toBe(1);

    // users を更新
    const updatedState = {
      user: {
        users: [
          { id: 1, name: 'John', isActive: true, age: 30 },
          { id: 2, name: 'Jane', isActive: true, age: 25 }, // アクティブに変更
          { id: 3, name: 'Bob', isActive: true, age: 35 }, // 新規追加
        ],
      },
    };

    const updatedActiveUsers =
      selectActiveUsers(updatedState);
    const updatedStats = selectUserStatistics(updatedState);

    expect(updatedActiveUsers).toHaveLength(3);
    expect(updatedStats.active).toBe(3);
    expect(updatedStats.total).toBe(3);
  });

  test('selector should maintain referential equality when dependencies unchanged', () => {
    const state1 = {
      user: {
        users: [{ id: 1, name: 'John', isActive: true }],
      },
    };

    const state2 = {
      user: {
        users: [{ id: 1, name: 'John', isActive: true }], // 同じ内容
      },
    };

    const result1 = selectActiveUsers(state1);
    const result2 = selectActiveUsers(state2);

    // 内容は同じだが参照は異なるため、再計算される
    expect(result1).toEqual(result2);
    expect(result1).not.toBe(result2);
  });

  test('selector should handle circular dependencies gracefully', () => {
    // 循環依存のテストケース
    // 実際のアプリケーションでは避けるべきだが、
    // 万が一発生した場合の動作を確認

    const state = {
      user: {
        users: [{ id: 1, name: 'John' }],
      },
    };

    // 正常に動作することを確認
    expect(() => {
      selectUsers(state);
      selectActiveUsers(state);
      selectUserStatistics(state);
    }).not.toThrow();
  });
});

これらのテスト手法により、Selector の動作を包括的に検証し、Redux アプリケーションのデータ取得・変換ロジックの信頼性を確保できます。次のセクションでは、Store 統合テストの実践について詳しく解説いたします。

Store 統合テストの実践

Store 全体の動作テスト

個別のコンポーネントテストに加えて、Redux Store 全体の統合テストは、実際のアプリケーションの動作を検証する上で重要です。

javascript// src/store/index.js
import {
  createStore,
  applyMiddleware,
  combineReducers,
} from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';

import userReducer from './reducers/userReducer';
import productReducer from './reducers/productReducer';
import uiReducer from './reducers/uiReducer';

const rootReducer = combineReducers({
  user: userReducer,
  products: productReducer,
  ui: uiReducer,
});

export const configureStore = (initialState) => {
  const middlewares = [thunk];

  const store = createStore(
    rootReducer,
    initialState,
    composeWithDevTools(applyMiddleware(...middlewares))
  );

  return store;
};

export default configureStore;

Store 統合テストの実装:

javascript// src/store/__tests__/storeIntegration.test.js
import { configureStore } from '../index';
import * as userActions from '../actions/userActions';
import * as userAPI from '../../services/userAPI';
import {
  selectCurrentUser,
  selectUsers,
  selectUserLoading,
  selectUserError,
} from '../selectors/userSelectors';

// API のモック
jest.mock('../../services/userAPI');
const mockUserAPI = userAPI;

describe('Store Integration Tests', () => {
  let store;

  beforeEach(() => {
    store = configureStore();
    jest.clearAllMocks();
  });

  describe('User Management Flow', () => {
    test('should handle complete user fetch flow', async () => {
      const mockUser = {
        id: 1,
        firstName: 'John',
        lastName: 'Doe',
        email: 'john.doe@example.com',
      };

      mockUserAPI.fetchUserById.mockResolvedValue(mockUser);

      // 初期状態の確認
      expect(selectUserLoading(store.getState())).toBe(
        false
      );
      expect(
        selectCurrentUser(store.getState())
      ).toBeNull();
      expect(selectUserError(store.getState())).toBeNull();

      // 非同期アクションの実行
      const fetchPromise = store.dispatch(
        userActions.fetchUser(1)
      );

      // ローディング状態の確認
      expect(selectUserLoading(store.getState())).toBe(
        true
      );
      expect(selectUserError(store.getState())).toBeNull();

      // 非同期処理の完了待ち
      await fetchPromise;

      // 最終状態の確認
      const finalState = store.getState();
      expect(selectUserLoading(finalState)).toBe(false);
      expect(selectCurrentUser(finalState)).toEqual(
        mockUser
      );
      expect(selectUserError(finalState)).toBeNull();
    });

    test('should handle user fetch error flow', async () => {
      const errorMessage =
        'Network Error: Failed to fetch user';
      mockUserAPI.fetchUserById.mockRejectedValue(
        new Error(errorMessage)
      );

      // エラーが発生する非同期アクションの実行
      await expect(
        store.dispatch(userActions.fetchUser(1))
      ).rejects.toThrow(errorMessage);

      // エラー状態の確認
      const finalState = store.getState();
      expect(selectUserLoading(finalState)).toBe(false);
      expect(selectCurrentUser(finalState)).toBeNull();
      expect(selectUserError(finalState)).toBe(
        errorMessage
      );
    });

    test('should handle multiple user operations', async () => {
      const user1 = { id: 1, name: 'John Doe' };
      const user2 = { id: 2, name: 'Jane Smith' };
      const updatedUser1 = { id: 1, name: 'John Updated' };

      mockUserAPI.fetchUserById
        .mockResolvedValueOnce(user1)
        .mockResolvedValueOnce(user2);
      mockUserAPI.updateUser.mockResolvedValue(
        updatedUser1
      );

      // 複数のユーザーを順次取得
      await store.dispatch(userActions.fetchUser(1));
      await store.dispatch(
        userActions.addUserToList(user2)
      );

      let currentState = store.getState();
      expect(selectUsers(currentState)).toHaveLength(2);

      // ユーザー情報の更新
      await store.dispatch(
        userActions.updateUserWithOptimisticUpdate(1, {
          name: 'John Updated',
        })
      );

      currentState = store.getState();
      const updatedUser = selectUsers(currentState).find(
        (u) => u.id === 1
      );
      expect(updatedUser.name).toBe('John Updated');
    });
  });

  describe('Cross-Module Interactions', () => {
    test('should handle interactions between user and UI state', async () => {
      // UI状態の初期化
      store.dispatch({
        type: 'UI_SET_LOADING',
        payload: true,
      });

      // ユーザー取得の開始
      const mockUser = { id: 1, name: 'John Doe' };
      mockUserAPI.fetchUserById.mockResolvedValue(mockUser);

      await store.dispatch(userActions.fetchUser(1));

      // UI状態をクリア
      store.dispatch({
        type: 'UI_SET_LOADING',
        payload: false,
      });

      const finalState = store.getState();
      expect(finalState.ui.loading).toBe(false);
      expect(selectCurrentUser(finalState)).toEqual(
        mockUser
      );
    });

    test('should maintain state consistency across modules', () => {
      // 複数のモジュールで状態を変更
      store.dispatch({
        type: 'USER_SET_LOADING',
        payload: true,
      });
      store.dispatch({
        type: 'PRODUCTS_SET_LOADING',
        payload: true,
      });
      store.dispatch({
        type: 'UI_SET_MODAL_OPEN',
        payload: true,
      });

      const state = store.getState();

      // 各モジュールの状態が独立して管理されていることを確認
      expect(state.user.loading).toBe(true);
      expect(state.products.loading).toBe(true);
      expect(state.ui.modalOpen).toBe(true);

      // 一つのモジュールの変更が他に影響しないことを確認
      store.dispatch({
        type: 'USER_SET_LOADING',
        payload: false,
      });

      const updatedState = store.getState();
      expect(updatedState.user.loading).toBe(false);
      expect(updatedState.products.loading).toBe(true); // 変更されない
      expect(updatedState.ui.modalOpen).toBe(true); // 変更されない
    });
  });

  describe('State Persistence and Hydration', () => {
    test('should initialize store with provided initial state', () => {
      const initialState = {
        user: {
          currentUser: { id: 1, name: 'Existing User' },
          users: [{ id: 1, name: 'Existing User' }],
          loading: false,
          error: null,
        },
      };

      const storeWithInitialState =
        configureStore(initialState);
      const state = storeWithInitialState.getState();

      expect(selectCurrentUser(state)).toEqual(
        initialState.user.currentUser
      );
      expect(selectUsers(state)).toEqual(
        initialState.user.users
      );
    });

    test('should handle partial initial state', () => {
      const partialInitialState = {
        user: {
          currentUser: { id: 1, name: 'Partial User' },
          // 他のプロパティは省略
        },
      };

      const storeWithPartialState = configureStore(
        partialInitialState
      );
      const state = storeWithPartialState.getState();

      // 提供された状態は設定される
      expect(selectCurrentUser(state)).toEqual(
        partialInitialState.user.currentUser
      );

      // 省略された状態はデフォルト値が使用される
      expect(selectUsers(state)).toEqual([]); // デフォルト値
      expect(selectUserLoading(state)).toBe(false); // デフォルト値
    });
  });
});

Middleware を含む統合テスト

Redux Middleware の動作を含めた統合テスト:

javascript// src/store/__tests__/middlewareIntegration.test.js
import { configureStore } from '../index';
import * as userActions from '../actions/userActions';

// カスタムミドルウェアの例
const actionLoggerMiddleware =
  (store) => (next) => (action) => {
    console.log('Dispatching action:', action);
    const result = next(action);
    console.log('New state:', store.getState());
    return result;
  };

const errorHandlingMiddleware =
  (store) => (next) => (action) => {
    try {
      return next(action);
    } catch (error) {
      console.error('Error in action:', action, error);
      store.dispatch({
        type: 'GLOBAL_ERROR',
        payload: error.message,
      });
      throw error;
    }
  };

describe('Middleware Integration Tests', () => {
  let store;
  let consoleSpy;

  beforeEach(() => {
    consoleSpy = jest
      .spyOn(console, 'log')
      .mockImplementation(() => {});
    store = configureStore();
  });

  afterEach(() => {
    consoleSpy.mockRestore();
  });

  test('should apply thunk middleware for async actions', async () => {
    // Thunk が正しく動作することを確認
    const thunkAction = (dispatch, getState) => {
      dispatch({ type: 'THUNK_START' });
      return Promise.resolve('thunk result');
    };

    const result = await store.dispatch(thunkAction);
    expect(result).toBe('thunk result');
  });

  test('should handle middleware chain correctly', () => {
    // ミドルウェアチェーンの動作確認
    const action = { type: 'TEST_ACTION', payload: 'test' };

    store.dispatch(action);

    // アクションが正常に処理されることを確認
    const state = store.getState();
    expect(state).toBeDefined();
  });

  test('should handle async action errors in middleware', async () => {
    const errorAction = () => {
      return async (dispatch) => {
        dispatch({ type: 'ERROR_ACTION_START' });
        throw new Error('Async action error');
      };
    };

    await expect(
      store.dispatch(errorAction())
    ).rejects.toThrow('Async action error');
  });
});

Time Travel Debugging のテスト活用

Redux DevTools の Time Travel 機能を活用したテスト手法:

javascript// src/store/__tests__/timeTravelDebugging.test.js
import { configureStore } from '../index';
import * as userActions from '../actions/userActions';

describe('Time Travel Debugging Tests', () => {
  let store;
  let actionHistory;

  beforeEach(() => {
    store = configureStore();
    actionHistory = [];

    // アクション履歴を記録するミドルウェア
    const historyMiddleware =
      (store) => (next) => (action) => {
        actionHistory.push({
          action,
          stateBefore: store.getState(),
        });
        const result = next(action);
        actionHistory.push({
          action,
          stateAfter: store.getState(),
        });
        return result;
      };

    // テスト用のストアを再作成(ミドルウェア付き)
    store = createStore(
      rootReducer,
      applyMiddleware(thunk, historyMiddleware)
    );
  });

  test('should track action history for debugging', () => {
    // 一連のアクションを実行
    store.dispatch({
      type: 'USER_SET_LOADING',
      payload: true,
    });
    store.dispatch({
      type: 'USER_SET_CURRENT_USER',
      payload: { id: 1, name: 'John' },
    });
    store.dispatch({
      type: 'USER_SET_LOADING',
      payload: false,
    });

    // アクション履歴が正しく記録されていることを確認
    expect(actionHistory).toHaveLength(6); // 各アクションで before/after の2エントリ

    // 最初のアクション
    expect(actionHistory[0].action.type).toBe(
      'USER_SET_LOADING'
    );
    expect(actionHistory[0].stateBefore.user.loading).toBe(
      false
    );
    expect(actionHistory[1].stateAfter.user.loading).toBe(
      true
    );
  });

  test('should enable state snapshot comparison', () => {
    const initialState = store.getState();

    // 状態を変更
    store.dispatch({
      type: 'USER_SET_CURRENT_USER',
      payload: { id: 1, name: 'John' },
    });

    const stateAfterUserSet = store.getState();

    store.dispatch({
      type: 'USER_ADD_TO_LIST',
      payload: { id: 2, name: 'Jane' },
    });

    const finalState = store.getState();

    // 各段階での状態変化を検証
    expect(initialState.user.currentUser).toBeNull();
    expect(stateAfterUserSet.user.currentUser).toEqual({
      id: 1,
      name: 'John',
    });
    expect(finalState.user.users).toHaveLength(1);
  });

  test('should replay actions for regression testing', () => {
    // 実際のユーザーインタラクションを模擬したアクション列
    const userInteractionActions = [
      { type: 'USER_LOGIN_START' },
      {
        type: 'USER_LOGIN_SUCCESS',
        payload: { id: 1, name: 'John' },
      },
      { type: 'USER_FETCH_PROFILE_START' },
      {
        type: 'USER_FETCH_PROFILE_SUCCESS',
        payload: { bio: 'Developer' },
      },
      {
        type: 'USER_UPDATE_PREFERENCES',
        payload: { theme: 'dark' },
      },
    ];

    // アクションを順次実行
    userInteractionActions.forEach((action) => {
      store.dispatch(action);
    });

    const finalState = store.getState();

    // 期待される最終状態を検証
    expect(finalState.user.currentUser).toEqual({
      id: 1,
      name: 'John',
      bio: 'Developer',
    });
    expect(finalState.user.preferences.theme).toBe('dark');

    // 同じアクション列を再実行して一貫性を確認
    const newStore = configureStore();
    userInteractionActions.forEach((action) => {
      newStore.dispatch(action);
    });

    expect(newStore.getState()).toEqual(finalState);
  });

  test('should handle action replay with error scenarios', () => {
    const actionsWithError = [
      { type: 'USER_FETCH_START' },
      {
        type: 'USER_FETCH_FAILURE',
        payload: 'Network error',
      },
      { type: 'USER_RETRY_FETCH' },
      {
        type: 'USER_FETCH_SUCCESS',
        payload: { id: 1, name: 'John' },
      },
    ];

    // エラーを含むアクション列の実行
    actionsWithError.forEach((action) => {
      store.dispatch(action);
    });

    const finalState = store.getState();

    // エラー状態からの回復を確認
    expect(finalState.user.error).toBeNull();
    expect(finalState.user.currentUser).toEqual({
      id: 1,
      name: 'John',
    });
  });
});

パフォーマンステストとメモリリーク検証

Store のパフォーマンスとメモリ使用量の検証:

javascript// src/store/__tests__/performanceTests.test.js
import { configureStore } from '../index';
import * as userActions from '../actions/userActions';

describe('Store Performance Tests', () => {
  let store;

  beforeEach(() => {
    store = configureStore();
  });

  test('should handle large number of actions efficiently', () => {
    const startTime = performance.now();

    // 大量のアクションを実行
    for (let i = 0; i < 1000; i++) {
      store.dispatch({
        type: 'USER_ADD_TO_LIST',
        payload: { id: i, name: `User ${i}` },
      });
    }

    const endTime = performance.now();
    const executionTime = endTime - startTime;

    // 実行時間が合理的な範囲内であることを確認
    expect(executionTime).toBeLessThan(1000); // 1秒以内

    // 最終状態の確認
    const finalState = store.getState();
    expect(finalState.user.users).toHaveLength(1000);
  });

  test('should not cause memory leaks with repeated actions', () => {
    const initialMemory = process.memoryUsage().heapUsed;

    // 同じアクションを繰り返し実行
    for (let i = 0; i < 100; i++) {
      store.dispatch({
        type: 'USER_SET_LOADING',
        payload: true,
      });
      store.dispatch({
        type: 'USER_SET_LOADING',
        payload: false,
      });
    }

    // ガベージコレクションを強制実行(Node.js環境)
    if (global.gc) {
      global.gc();
    }

    const finalMemory = process.memoryUsage().heapUsed;
    const memoryIncrease = finalMemory - initialMemory;

    // メモリ使用量の増加が合理的な範囲内であることを確認
    expect(memoryIncrease).toBeLessThan(1024 * 1024); // 1MB以内
  });

  test('should optimize selector performance with memoization', () => {
    // 大量のユーザーデータを設定
    const users = Array.from({ length: 1000 }, (_, i) => ({
      id: i,
      name: `User ${i}`,
      isActive: i % 2 === 0,
    }));

    store.dispatch({
      type: 'USER_SET_USERS',
      payload: users,
    });

    const startTime = performance.now();

    // 同じselectorを複数回呼び出し
    for (let i = 0; i < 100; i++) {
      selectActiveUsers(store.getState());
    }

    const endTime = performance.now();
    const executionTime = endTime - startTime;

    // メモ化により高速に実行されることを確認
    expect(executionTime).toBeLessThan(100); // 100ms以内
  });
});

まとめ

本記事では、Jest と Redux を組み合わせた包括的なテスト手法について詳しく解説いたしました。Redux アプリケーションの各層における効果的なテスト戦略を習得することで、より信頼性の高いアプリケーション開発が可能になります。

重要なポイントの再確認

テスト戦略の立て方では、テストピラミッドの考え方を適用し、Unit Tests(70%)、Integration Tests(20%)、E2E Tests(10%)の適切な配分でテストを実装することが重要です。特に Redux では、Pure Function である Reducer のテストに重点を置き、ビジネスロジックを含む部分を優先的にテストすることが効果的です。

Reducer のテストでは、State の不変性チェックやエラーハンドリングの検証が特に重要になります。deepFreeze を活用した不変性違反の検出や、初期状態の適切な処理確認により、予期しないバグを防止できます。

Action と Action Creator のテストでは、FSA(Flux Standard Action)準拠の確認や Action Type の一意性検証が重要です。特に大規模なアプリケーションでは、Action Type の重複を防ぐためのテストが必須となります。

非同期 Action のテストでは、redux-mock-store を活用したモック化と、リトライロジックやエラーハンドリングの詳細な検証が重要です。実際のプロダクション環境で発生する様々なエラーパターンを想定したテストにより、アプリケーションの堅牢性を大幅に向上させることができます。

Selector のテストでは、Reselect のメモ化機能の動作確認と、複雑な計算ロジックの検証が重要になります。特に依存関係のテストにより、Selector 間の連携が正しく動作することを確認できます。

Store 統合テストでは、個別のコンポーネントテストでは検出できない、モジュール間の相互作用やミドルウェアの動作を検証できます。Time Travel Debugging を活用したテスト手法により、複雑な状態遷移の検証も可能になります。

実践における注意点

Redux テストを実装する際は、テストの実行速度メンテナンス性のバランスを考慮することが重要です。過度に詳細なテストは実行時間を増加させ、開発効率を低下させる可能性があります。

また、実際のユーザーフローを意識したテストシナリオの作成により、より実用的なテストが可能になります。単純な単体テストだけでなく、ユーザーの操作パターンを模擬した統合テストを組み合わせることで、実際のバグ検出率を向上させることができます。

エラーメッセージの詳細化も重要な要素です。テスト失敗時に、問題の特定と修正が迅速に行えるよう、具体的で分かりやすいエラーメッセージを提供することで、開発チーム全体の生産性向上に繋がります。

Redux のテスト実装は初期の学習コストは高いものの、長期的なアプリケーションの品質向上と開発効率の改善に大きく貢献します。本記事で紹介した手法を段階的に導入し、プロジェクトの特性に合わせてカスタマイズすることで、より効果的な Redux テスト環境を構築していただければと思います。

関連リンク