如果你写过这样的测试:验证 dispatch({ type: 'FETCH_USER_SUCCESS' }) 被调用时传入的 payload 结构完全匹配,那么你的测试会在每次有人重命名常量时崩溃。

这不是在测试你的状态逻辑,而是在测试你的手指有没有敲对字符串。

Redux 测试教程通常以 Jest mock 开头:spy 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 本身就是一个纯函数:给定一个 state 和一个 action,返回新的 state。最诚实的测试方式就是喂它一些 action,然后检查 state。

你不需要 mock reducer,也不需要 mock action creator。直接搭一个真实的 store,dispatch 真实的 action(或 action creator),然后读取最终的状态。

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 和异步逻辑

测试异步中间件是大多数团队伸手去拿 mock 的地方。其实你不必这么做。

如果你在用 Redux Toolkit,configureStore 已经内置了 redux-thunk 和 devtools 中间件。带有相同中间件栈的测试 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 客户端。无论哪种方式,你的断言都聚焦于 state,而不是哪个函数按什么顺序被调用。

什么时候该 mock,什么时候不该

我并不是主张零 mock。mock 适用于系统边界:HTTP 请求、浏览器 API、定时器、有副作用的外部库。

Reducer 不是系统边界。它们是你自己代码库里的确定性函数。mock 它们就像 mock 一个 filter 调用一样荒谬。

Action creator,尤其是 thunk,属于编排逻辑。你可以通过它们对 state 的影响来测试,而不是检查内部的 dispatch 调用。这样就把测试与 action 的形状解耦了。

代价是速度。跑完整 reducer 栈和中间件的测试确实比 mock 断言慢。但在实践中,差距只是毫秒级。如果你的测试套件很慢,罪魁祸首几乎从来不是 Redux store 测试。真正拖慢的是每个文件都启动 JSDOM 实例、调用真实数据库、或者没清理定时器。

如果你不用 Redux Toolkit 呢?

原生 Redux 也是一样的做法。用 createStore 构建 store,应用你的中间件,然后 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);
});

如果你有复杂的中间件链(日志、埋点、saga),可以把它放进测试 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。一致性让队友更容易写出行为相同的测试,也给了你一个地方来调整中间件或添加测试专用的 reducer。

这套方法行不通的地方

有些场景你确实需要在 action 层面测试。如果你有自定义中间件会转换 action,你就需要断言转换后的 action。如果你在开发一个导出 action creator 的库,action 的结构 就是 公共 API。

但对于应用代码来说,action 是实现细节。state 才是关键。

常见问题

我应该测试 connected React 组件吗?

用 mock 的 props 单独测试组件,或者用 @testing-library/react 和真实的 provider 做集成测试。两种都可行。关键是不要通过组件去测试 Redux 的管道,要测试用户看到的东西。

那 saga 呢?

Redux-Saga 是另一回事,因为它管理自己的控制流。你可以用 redux-saga-test-plan 运行 saga 并断言状态变化,也可以用 cloneableGenerator 单独测试 saga。Saga 是唯一一个通常值得 mock effect 的地方。

像埋点这样的副作用怎么处理?

把它们提取到中间件里并单独测试中间件,或者用 extraArgument 模式把 mock 的 analytics 客户端注入到你的 thunk 中。

这适用于 Zustand 或其他状态管理库吗?

适用。原则是一样的:初始化 store,触发状态变化,读取 state。Zustand 的 store 甚至更简单,因为它们往往只是函数。

在一个 slice 上试试

选你最常改动的一个 slice。把一个重度 mock 的测试换成真实 store 测试。跑一下。如果慢了超过 10 毫秒,我请你喝咖啡。

大多数团队发现,一旦他们停止验证 dispatch 调用、开始验证 state,Redux 测试就会变得更短、更能扛住重构,而且真的能抓到 bug。