如果你曾經寫過一個測試,去驗證 dispatch({ type: 'FETCH_USER_SUCCESS' }) 是否被以完全正確的 payload 形狀呼叫,那你其實寫了一個「只要有人重新命名常數就會壞掉」的測試。

這不是測試你的狀態邏輯。這是在測試你的手指有沒有打對字串。

Redux 測試教學通常從 Jest mock 開始:監聽 dispatch,斷言 action creator 被呼叫了,斷言 type 吻合,結束。這對教學來說夠用。但在真正的程式碼庫裡,action type 會變、payload 會重構,你的測試就會變成應用程式裡每一次 dispatch 的待辦清單。

另一種做法是把 store 當成一個單元來測試,而不是把每個 action creator 當成獨立個體。

為什麼逐個 action 測試在規模放大後會崩潰

想像一個使用者個人資料元件,它會抓取資料、處理載入狀態、顯示錯誤。天真的測試長這樣:

// The mock spiral
jest.mock('./api', () => ({ fetchUser: jest.fn() }));

it('dispatches fetch user action', async () => {
  const dispatch = jest.fn();
  await loadUser(42)(dispatch);
  expect(dispatch).toHaveBeenCalledWith({
    type: 'FETCH_USER_REQUEST',
  });
  expect(dispatch).toHaveBeenCalledWith({
    type: 'FETCH_USER_SUCCESS',
    payload: { id: 42, name: 'Ada' },
  });
});

這個測試會通過。但只要有人在 success action 上加一個 meta 欄位、把 payload 包進 data 鍵,或者改用 createAsyncThunk,它就會馬上失敗。

更重要的是,它完全無法告訴你 reducer 到底有沒有對那個 action 做任何事。action 可能飛進一個完全忽略它的 reducer,而測試還是綠燈。

整合測試思維:把 store 當成黑箱

store 本身就是一個 pure function。給定一個 state 和一個 action,它回傳新的 state。最誠實的測試方式就是餵它 action,然後檢查 state。

你不需要 mock reducer。你不需要 mock action creator。你架設一個真正的 store,dispatch 真正的 action(或 action creator),然後讀取最終的 state。

import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './userSlice';

function createTestStore(preloadedState) {
  return configureStore({
    reducer: { user: userReducer },
    preloadedState,
  });
}

it('loads a user into state', () => {
  const store = createTestStore();
  store.dispatch({ type: 'user/fetchUser/fulfilled', payload: { id: 42, name: 'Ada' } });

  expect(store.getState().user.entities[42]).toEqual({ id: 42, name: 'Ada' });
});

這只有一行 dispatch 和一行斷言。它測試的是你的 UI 真正依賴的契約:某件事發生之後,state 長什麼樣子。

用真實 store 測試 thunk 與非同步邏輯

測試非同步 middleware 是多數團隊伸手去拿 mock 的時候。你其實不用。

如果你在用 Redux Toolkit,configureStore 已經內建 redux-thunk 和開發工具 middleware。一個帶有相同 middleware 堆疊的測試 store,行為會跟正式環境的 store 完全一致。

import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';

function createTestStore(preloadedState) {
  return configureStore({
    reducer: { user: userReducer },
    preloadedState,
    middleware: (getDefault) => getDefault(), // same thunk middleware
  });
}

it('handles the full fetch lifecycle', async () => {
  const store = createTestStore();

  // dispatch the thunk
  const promise = store.dispatch(fetchUser(42));

  // check loading state immediately
  expect(store.getState().user.loading).toBe('pending');

  await promise;

  // check final state
  expect(store.getState().user.loading).toBe('idle');
  expect(store.getState().user.entities[42].name).toBe('Ada');
});

如果 fetchUser 會呼叫 API,你還是需要處理網路層。但那是 fetch 的問題,不是 Redux 的問題。你可以用 msw 攔截 HTTP 請求,或者透過 thunk 的 extraArgument 傳入自訂的 API client。無論哪種方式,你的斷言都應該聚焦在 state,而不是哪個函式以什麼順序被呼叫。

什麼時候該 mock,什麼時候不該

我不是主張零 mock。Mock 適合用在系統邊界:HTTP 請求、瀏覽器 API、計時器、具有side effect的外部函式庫。

Reducer 不是系統邊界。它們是你自己程式碼庫裡的確定性函式。Mock 它們就像 mock 一個 filter 呼叫。

Action creator,尤其是 thunk,屬於編排邏輯。你可以透過它們對 state 造成的影響來測試,而不是去測試它們內部的 dispatch 呼叫。這麼做能解除測試與 action 形狀之間的耦合。

代價是速度。一個執行完整 reducer 堆疊與 middleware 的測試,會比只斷言 mock 的測試慢一點。實務上,差異是毫秒等級。如果你的測試套件很慢,兇手幾乎從來不是 Redux store 測試。通常是每個檔案都啟動 JSDOM,或者呼叫真實資料庫,或者沒有清理計時器。

如果你沒有用 Redux Toolkit 呢?

純 Redux 的運作方式完全一樣。用 createStore 建立 store,套用你的 middleware,然後 dispatch。

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';

function createTestStore(preloadedState) {
  return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}

it('toggles the feature flag', () => {
  const store = createTestStore({ features: { darkMode: false } });
  store.dispatch({ type: 'features/TOGGLE_DARK_MODE' });

  expect(store.getState().features.darkMode).toBe(true);
});

如果你有複雜的 middleware 鏈(logging、analytics、sagas),可以把它們放進測試 store,或者精簡掉。目標是測試元件依賴的行為,而不是完全複製正式環境。

Selector 測試:契約的另一半

只測試 store 本身只講了一半的故事。你的元件透過 selector 讀取 state。Selector 測試同樣簡單,也同樣有價值。

import { selectUserById, selectActiveUsers } from './userSelectors';

it('selects only active users', () => {
  const state = {
    user: {
      entities: {
        1: { id: 1, name: 'Ada', active: true },
        2: { id: 2, name: 'Grace', active: false },
      },
      ids: [1, 2],
    },
  };

  expect(selectActiveUsers(state)).toHaveLength(1);
  expect(selectActiveUsers(state)[0].name).toBe('Ada');
});

注意 selector 測試根本沒有碰觸 store。它拿一個 state 形狀,回傳衍生值。這就是為什麼它快,也是為什麼它精確。Reducer 與 selector 之間的契約就是 state 形狀,而兩邊可以獨立測試。

大型程式碼庫的模式

在有很多 slice 的程式碼庫裡,每個測試檔案都建一個測試 store 會變得很重複。把它集中化。

// test-utils/store.js
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from '../src/store';

export function createTestStore(preloadedState = {}) {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
    middleware: (getDefault) =>
      getDefault({
        // disable immutable and serializable checks in tests
        // to avoid noise when testing edge cases
        immutableCheck: false,
        serializableCheck: false,
      }),
  });
}

然後在每個測試裡匯入 createTestStore。一致性讓隊友更容易寫出行為一致的測試,也讓你有一個地方可以調整 middleware 或加入測試專用的 reducer。

這招什麼時候會失效

有些情況下你確實想在 action 層級測試。如果你有會轉換 action 的自訂 middleware,你需要斷言轉換後的 action。如果你在開發一個會匯出 action creator 的函式庫,action 形狀就是公開 API。

但對於應用程式碼來說,action 是實作細節。State 才是重點。

常見問題

我該測試連接了 Redux 的 React 元件嗎?

用 mock 的 props 單獨測試元件,或者用 @testing-library/react 與真正的 provider 做整合測試。兩種都可行。關鍵是不要透過元件去測試 Redux 的管線。要測試使用者看到了什麼。

那 sagas 呢?

Redux-Saga 是另一種生物,因為它自己管理控制流程。你可以用 redux-saga-test-plan 執行 saga 並斷言 state 變化,或者用 cloneableGenerator 單獨測試 saga。Saga 是唯一一個「mock effect 通常值得」的地方。

怎麼處理 analytics 這類side effect?

把它們抽出來變成 middleware,然後單獨測試 middleware;或者使用 extraArgument 模式,把 mock analytics client 注入你的 thunk。

這對 Zustand 或其他狀態管理器也適用嗎?

適用。原則都一樣:初始化 store、觸發狀態變化、讀取 state。Zustand store 甚至更簡單,因為它們通常只是函式。

先從一個 slice 試起

挑你最常動到的那個 slice。把一個充滿 mock 的測試換成真實 store 測試。跑一下。如果它慢了超過 10ms,我請你喝咖啡。

大多數團隊會發現,一旦他們停止驗證 dispatch 呼叫、開始驗證 state,Redux 測試就會變得更短、更能撐過重構,而且真的會抓到 bug。