在这里插入图片描述

每日一句正能量

“决定牺牲自己成全别人时,要看这件事是否值得,因为你的时间和精力同样值钱。”
你的善良必须带点锋芒,牺牲不是美德,把资源投在值得的地方才是。在把自己烧光之前,先问问对方值不值得你剪下这一截烛芯。

摘要

摘要:上一篇文章我们搭建了一套基于 Redux 风格的 HarmonyOS 跨页面状态管理方案。状态管理逻辑的正确性直接决定应用的数据一致性,而单元测试是保障这一正确性的第一道防线。本文系统讲解如何为 Redux 风格的 Store、Reducer、Action、Selector 及 Middleware 编写高覆盖率的单元测试,涵盖测试策略设计、Mock 隔离、异步测试、快照测试等实战技巧,提供可直接运行的测试代码,帮助开发者在 HarmonyOS 生态中建立高置信度的状态管理测试体系。


一、为什么状态管理必须写单元测试

状态管理是应用的"中枢神经",一旦出错,影响面会扩散到所有依赖该状态的页面和组件。在实际开发中,以下场景极易引入状态 Bug:

  • Reducer 逻辑遗漏:新增 Action 类型时忘记在 switch 分支中处理,导致旧状态直接返回。
  • 状态突变(Mutation):开发者在 Reducer 中直接修改 state.items.push(),破坏了不可变性,导致 Redux 无法检测到状态变更。
  • Selector 缓存失效:Selector 的 memoization 逻辑有误,导致 UI 重复渲染或数据不同步。
  • Middleware 执行顺序错误:多个中间件组合时,异步中间件拦截了本应同步执行的 Action。
  • 边界条件未覆盖:空数组、空对象、极大数值、网络超时等异常场景未处理。

这些问题在编译期无法发现,在运行时可能偶发,调试成本极高。单元测试的核心价值在于:在代码提交前,以最低成本、最快速度捕获上述问题。Redux 架构的纯函数特性(给定相同输入,永远返回相同输出,无副作用)使其天然具备"可测试性",这是其他架构难以比拟的优势。


二、HarmonyOS 单元测试体系概览

2.1 测试金字塔

在这里插入图片描述

HarmonyOS 测试体系遵循经典的测试金字塔模型,自上而下分为三层:

层级 测试类型 数量占比 运行速度 定位精度
顶层 E2E 端到端测试 ~10% 最慢(分钟级) 最低(需排查全链路)
中层 集成测试 ~30% 中等(秒级) 中等(模块间交互)
底层 单元测试 ~60% 最快(毫秒级) 最高(精准到函数)

单元测试是金字塔的基石。对于 Redux 状态管理,单元测试应覆盖 Action、Reducer、Selector 三个纯函数单元,以及 Store 和 Middleware 的集成行为。

2.2 HarmonyOS 测试框架

HarmonyOS 提供 @ohos/hypium 作为官方单元测试框架,支持以下核心能力:

  • 断言库assertEqualassertTrueassertThrowError 等丰富断言。
  • 异步测试async/await 配合 done 回调,支持 Promise 和回调式异步代码测试。
  • Mock 能力:通过 mock 函数替换模块依赖,隔离外部系统 API。
  • 参数化测试it 支持数组参数批量执行同一测试逻辑。
  • 测试生命周期beforeAllbeforeEachafterAllafterEach 管理测试环境。

三、测试覆盖范围与策略

在这里插入图片描述

上图展示了 Redux 状态管理五大测试维度及其典型覆盖率目标。下面逐一展开。


四、Action 单元测试

Action 是描述状态变更意图的普通对象。虽然 Action 本身逻辑简单,但 Action Creator(工厂函数)可能包含参数校验和默认值处理,需要测试覆盖。

4.1 同步 Action 测试

// actions/cartActions.ets
interface Action<T = object> {
  type: string;
  payload?: T;
}

function addToCart(itemId: string, quantity: number = 1): Action {
  if (!itemId || itemId.trim() === '') {
    throw new Error('itemId cannot be empty');
  }
  if (quantity <= 0) {
    throw new Error('quantity must be greater than 0');
  }
  return {
    type: 'CART_ADD_ITEM',
    payload: { itemId, quantity }
  };
}

function removeFromCart(itemId: string): Action {
  return {
    type: 'CART_REMOVE_ITEM',
    payload: itemId
  };
}

function clearCart(): Action {
  return { type: 'CART_CLEAR' };
}

export { addToCart, removeFromCart, clearCart, Action };
// test/actions/cartActions.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { addToCart, removeFromCart, clearCart } from '../../actions/cartActions';

describe('Cart Actions', () => {
  it('addToCart should create correct action with default quantity', () => {
    const action = addToCart('item-001');
    
    expect(action.type).assertEqual('CART_ADD_ITEM');
    expect(action.payload.itemId).assertEqual('item-001');
    expect(action.payload.quantity).assertEqual(1);
  });

  it('addToCart should create correct action with custom quantity', () => {
    const action = addToCart('item-002', 5);
    
    expect(action.payload.quantity).assertEqual(5);
  });

  it('addToCart should throw error when itemId is empty', () => {
    expect(() => addToCart('')).assertThrowError('itemId cannot be empty');
  });

  it('addToCart should throw error when quantity is zero or negative', () => {
    expect(() => addToCart('item-003', 0)).assertThrowError('quantity must be greater than 0');
    expect(() => addToCart('item-003', -1)).assertThrowError('quantity must be greater than 0');
  });

  it('removeFromCart should create correct action', () => {
    const action = removeFromCart('item-001');
    
    expect(action.type).assertEqual('CART_REMOVE_ITEM');
    expect(action.payload).assertEqual('item-001');
  });

  it('clearCart should create correct action', () => {
    const action = clearCart();
    
    expect(action.type).assertEqual('CART_CLEAR');
    expect(action.payload).assertUndefined();
  });
});

4.2 异步 Action(Thunk)测试

异步 Action 需要 Mock 网络请求和 dispatch 函数:

// test/actions/asyncActions.test.ets
import { describe, it, expect } from '@ohos/hypium';

describe('Async Cart Actions', () => {
  it('fetchCartItems should dispatch loading and success actions', async () => {
    // 准备 Mock 数据
    const mockItems = [
      { id: '1', name: '商品A', price: 100, quantity: 1 }
    ];
    
    // Mock 网络请求
    const mockFetch = async () => mockItems;
    
    // Mock dispatch 收集器
    const dispatchedActions: any[] = [];
    const mockDispatch = (action: any) => {
      dispatchedActions.push(action);
      return action;
    };
    
    const mockGetState = () => ({ cart: { items: [] } });
    
    // 执行 Thunk
    const thunk = fetchCartItems(mockFetch);
    await thunk(mockDispatch, mockGetState);
    
    // 断言 Action 序列
    expect(dispatchedActions.length).assertEqual(3);
    expect(dispatchedActions[0].type).assertEqual('CART_SET_LOADING');
    expect(dispatchedActions[0].payload).assertTrue();
    expect(dispatchedActions[1].type).assertEqual('CART_ADD_ITEM');
    expect(dispatchedActions[2].type).assertEqual('CART_SET_LOADING');
    expect(dispatchedActions[2].payload).assertFalse();
  });

  it('fetchCartItems should dispatch error action on failure', async () => {
    const mockFetch = async () => { throw new Error('Network Error'); };
    const dispatchedActions: any[] = [];
    const mockDispatch = (action: any) => {
      dispatchedActions.push(action);
      return action;
    };
    const mockGetState = () => ({ cart: { items: [] } });
    
    const thunk = fetchCartItems(mockFetch);
    await thunk(mockDispatch, mockGetState);
    
    // 即使失败,loading 也应该被重置
    const lastAction = dispatchedActions[dispatchedActions.length - 1];
    expect(lastAction.type).assertEqual('CART_SET_LOADING');
    expect(lastAction.payload).assertFalse();
  });
});

五、Reducer 单元测试

Reducer 是纯函数,是单元测试的"黄金目标"。测试策略围绕"给定旧状态和 Action,验证返回的新状态"展开。

5.1 购物车 Reducer 测试

// test/reducers/cartReducer.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { cartReducer, CartState, CartItem } from '../../modules/cart/cartReducer';

// 测试辅助函数:创建初始状态
function createInitialState(overrides?: Partial<CartState>): CartState {
  return {
    items: [],
    totalCount: 0,
    totalPrice: 0,
    isLoading: false,
    ...overrides
  };
}

// 测试辅助函数:创建商品
function createItem(overrides?: Partial<CartItem>): CartItem {
  return {
    id: 'item-001',
    name: '测试商品',
    price: 100,
    quantity: 1,
    image: 'test.png',
    ...overrides
  };
}

describe('Cart Reducer', () => {
  describe('Initial State', () => {
    it('should return initial state when state is undefined', () => {
      const state = cartReducer(undefined, { type: '@@INIT' });
      
      expect(state.items.length).assertEqual(0);
      expect(state.totalCount).assertEqual(0);
      expect(state.totalPrice).assertEqual(0);
      expect(state.isLoading).assertFalse();
    });
  });

  describe('CART_ADD_ITEM', () => {
    it('should add new item to empty cart', () => {
      const initialState = createInitialState();
      const item = createItem();
      
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: item
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.items[0].id).assertEqual('item-001');
      expect(newState.totalCount).assertEqual(1);
      expect(newState.totalPrice).assertEqual(100);
    });

    it('should increase quantity for existing item', () => {
      const existingItem = createItem({ quantity: 2 });
      const initialState = createInitialState({
        items: [existingItem],
        totalCount: 2,
        totalPrice: 200
      });
      
      const newItem = createItem({ quantity: 3 });
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: newItem
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.items[0].quantity).assertEqual(5); // 2 + 3
      expect(newState.totalCount).assertEqual(5);
      expect(newState.totalPrice).assertEqual(500);
    });

    it('should not mutate original state', () => {
      const initialState = createInitialState();
      const item = createItem();
      
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: item
      });
      
      // 验证原始状态未被修改
      expect(initialState.items.length).assertEqual(0);
      expect(initialState).not().assertDeepEquals(newState);
    });
  });

  describe('CART_REMOVE_ITEM', () => {
    it('should remove item by id', () => {
      const items = [
        createItem({ id: 'item-001', price: 100, quantity: 2 }),
        createItem({ id: 'item-002', price: 50, quantity: 1 })
      ];
      const initialState = createInitialState({
        items,
        totalCount: 3,
        totalPrice: 250
      });
      
      const newState = cartReducer(initialState, {
        type: 'CART_REMOVE_ITEM',
        payload: 'item-001'
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.items[0].id).assertEqual('item-002');
      expect(newState.totalCount).assertEqual(1);
      expect(newState.totalPrice).assertEqual(50);
    });

    it('should handle removing non-existent item gracefully', () => {
      const items = [createItem()];
      const initialState = createInitialState({ items, totalCount: 1, totalPrice: 100 });
      
      const newState = cartReducer(initialState, {
        type: 'CART_REMOVE_ITEM',
        payload: 'non-existent'
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.totalCount).assertEqual(1);
    });
  });

  describe('CART_UPDATE_QUANTITY', () => {
    it('should update quantity and recalculate totals', () => {
      const items = [createItem({ quantity: 2 })];
      const initialState = createInitialState({
        items,
        totalCount: 2,
        totalPrice: 200
      });
      
      const newState = cartReducer(initialState, {
        type: 'CART_UPDATE_QUANTITY',
        payload: { id: 'item-001', quantity: 5 }
      });
      
      expect(newState.items[0].quantity).assertEqual(5);
      expect(newState.totalCount).assertEqual(5);
      expect(newState.totalPrice).assertEqual(500);
    });

    it('should remove item when quantity is set to zero', () => {
      const items = [createItem(), createItem({ id: 'item-002' })];
      const initialState = createInitialState({
        items,
        totalCount: 2,
        totalPrice: 200
      });
      
      const newState = cartReducer(initialState, {
        type: 'CART_UPDATE_QUANTITY',
        payload: { id: 'item-001', quantity: 0 }
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.items[0].id).assertEqual('item-002');
    });
  });

  describe('CART_CLEAR', () => {
    it('should reset state to initial', () => {
      const initialState = createInitialState({
        items: [createItem()],
        totalCount: 1,
        totalPrice: 100
      });
      
      const newState = cartReducer(initialState, { type: 'CART_CLEAR' });
      
      expect(newState.items.length).assertEqual(0);
      expect(newState.totalCount).assertEqual(0);
      expect(newState.totalPrice).assertEqual(0);
    });
  });

  describe('Unknown Action', () => {
    it('should return current state for unknown action type', () => {
      const initialState = createInitialState({ items: [createItem()] });
      
      const newState = cartReducer(initialState, {
        type: 'UNKNOWN_ACTION',
        payload: {}
      });
      
      expect(newState).assertDeepEquals(initialState);
    });
  });

  describe('Boundary Conditions', () => {
    it('should handle empty string itemId in add', () => {
      const item = createItem({ id: '', name: '' });
      const initialState = createInitialState();
      
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: item
      });
      
      expect(newState.items.length).assertEqual(1);
      expect(newState.items[0].id).assertEqual('');
    });

    it('should handle very large quantity without overflow', () => {
      const item = createItem({ quantity: 999999 });
      const initialState = createInitialState();
      
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: item
      });
      
      expect(newState.totalCount).assertEqual(999999);
      expect(newState.totalPrice).assertEqual(99999900);
    });

    it('should handle negative price gracefully', () => {
      const item = createItem({ price: -10 });
      const initialState = createInitialState();
      
      const newState = cartReducer(initialState, {
        type: 'CART_ADD_ITEM',
        payload: item
      });
      
      expect(newState.totalPrice).assertEqual(-10);
    });
  });
});

5.2 Reducer 测试最佳实践

  1. 使用工厂函数创建测试数据:避免每个测试用例重复构造复杂的初始状态。
  2. 验证不可变性:每次状态变更后,断言原始状态未被修改。
  3. 覆盖边界条件:空数组、空字符串、极大/极小值、null/undefined。
  4. 测试未知 Action:确保 Reducer 对未识别的 Action 返回当前状态,而非抛出异常。

六、Selector 单元测试

Selector 负责从全局 State 中提取和计算派生数据。由于 Selector 通常带有缓存(memoization)逻辑,测试需要覆盖缓存命中和失效场景。

// test/selectors/cartSelectors.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { AppState } from '../../store/rootReducer';
import {
  selectCartItems,
  selectCartTotalCount,
  selectCartTotalPrice,
  selectIsCartEmpty,
  selectCartItemById
} from '../../selectors/cartSelectors';

describe('Cart Selectors', () => {
  // 构建测试用的全局 State
  function createMockState(cartOverrides?: any): AppState {
    return {
      cart: {
        items: [
          { id: '1', name: '商品A', price: 100, quantity: 2 },
          { id: '2', name: '商品B', price: 50, quantity: 3 }
        ],
        totalCount: 5,
        totalPrice: 350,
        isLoading: false,
        ...cartOverrides
      },
      user: {
        isLogin: true,
        token: 'test-token',
        userInfo: { userId: 'u001', nickname: 'Tester', avatar: '' }
      }
    } as AppState;
  }

  describe('selectCartItems', () => {
    it('should return cart items array', () => {
      const state = createMockState();
      const items = selectCartItems(state);
      
      expect(items.length).assertEqual(2);
      expect(items[0].id).assertEqual('1');
    });
  });

  describe('selectCartTotalCount', () => {
    it('should return correct total count', () => {
      const state = createMockState();
      expect(selectCartTotalCount(state)).assertEqual(5);
    });

    it('should return 0 for empty cart', () => {
      const state = createMockState({ items: [], totalCount: 0, totalPrice: 0 });
      expect(selectCartTotalCount(state)).assertEqual(0);
    });
  });

  describe('selectCartTotalPrice', () => {
    it('should return correct total price', () => {
      const state = createMockState();
      expect(selectCartTotalPrice(state)).assertEqual(350);
    });
  });

  describe('selectIsCartEmpty', () => {
    it('should return false when cart has items', () => {
      const state = createMockState();
      expect(selectIsCartEmpty(state)).assertFalse();
    });

    it('should return true when cart is empty', () => {
      const state = createMockState({ items: [] });
      expect(selectIsCartEmpty(state)).assertTrue();
    });
  });

  describe('selectCartItemById', () => {
    it('should return item by id', () => {
      const state = createMockState();
      const item = selectCartItemById(state, '2');
      
      expect(item).assertNotNull();
      expect(item?.name).assertEqual('商品B');
    });

    it('should return undefined for non-existent id', () => {
      const state = createMockState();
      const item = selectCartItemById(state, '999');
      
      expect(item).assertUndefined();
    });
  });

  describe('Memoization', () => {
    it('should return cached result when state reference unchanged', () => {
      const state = createMockState();
      const result1 = selectCartTotalPrice(state);
      const result2 = selectCartTotalPrice(state);
      
      // 如果实现了 memoization,两次调用应返回同一引用
      expect(result1).assertEqual(result2);
    });

    it('should recalculate when relevant state changes', () => {
      const state1 = createMockState();
      const result1 = selectCartTotalPrice(state1);
      
      const state2 = createMockState({
        items: [{ id: '1', name: '商品A', price: 200, quantity: 1 }],
        totalCount: 1,
        totalPrice: 200
      });
      const result2 = selectCartTotalPrice(state2);
      
      expect(result1).assertEqual(350);
      expect(result2).assertEqual(200);
    });
  });
});

七、Store 集成测试

Store 是状态管理的中枢,集成测试需要验证 Action 派发、状态更新、订阅通知的完整流程。

7.1 Store 核心行为测试

// test/store/ReduxStore.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { ReduxStore, Action } from '../../store/ReduxStore';
import { combineReducers } from '../../store/combineReducers';

// 简化版 Reducer 用于测试
interface TestState {
  count: number;
  message: string;
}

function testReducer(state: TestState = { count: 0, message: '' }, action: Action): TestState {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    case 'SET_MESSAGE':
      return { ...state, message: action.payload as string };
    default:
      return state;
  }
}

describe('ReduxStore', () => {
  let store: ReduxStore<TestState>;

  beforeEach(() => {
    ReduxStore.resetInstance();
    store = ReduxStore.getInstance(testReducer);
  });

  describe('Initialization', () => {
    it('should initialize with reducer default state', () => {
      const state = store.getState();
      expect(state.count).assertEqual(0);
      expect(state.message).assertEqual('');
    });

    it('should initialize with preloaded state', () => {
      ReduxStore.resetInstance();
      const preloaded = { count: 10, message: 'hello' };
      const customStore = ReduxStore.getInstance(testReducer, preloaded);
      
      expect(customStore.getState().count).assertEqual(10);
      expect(customStore.getState().message).assertEqual('hello');
    });

    it('should return same instance for singleton', () => {
      const instance1 = ReduxStore.getInstance(testReducer);
      const instance2 = ReduxStore.getInstance(testReducer);
      expect(instance1).assertEqual(instance2);
    });
  });

  describe('Dispatch', () => {
    it('should update state after dispatch', () => {
      store.dispatch({ type: 'INCREMENT' });
      expect(store.getState().count).assertEqual(1);
      
      store.dispatch({ type: 'INCREMENT' });
      expect(store.getState().count).assertEqual(2);
    });

    it('should handle multiple action types', () => {
      store.dispatch({ type: 'INCREMENT' });
      store.dispatch({ type: 'SET_MESSAGE', payload: 'test' });
      
      const state = store.getState();
      expect(state.count).assertEqual(1);
      expect(state.message).assertEqual('test');
    });

    it('should not change state for unknown action', () => {
      const prevState = store.getState();
      store.dispatch({ type: 'UNKNOWN' });
      
      expect(store.getState()).assertDeepEquals(prevState);
    });
  });

  describe('Subscribe', () => {
    it('should notify subscribers on state change', () => {
      let callCount = 0;
      let receivedState: TestState | null = null;
      
      store.subscribe((state) => {
        callCount++;
        receivedState = state;
      });
      
      store.dispatch({ type: 'INCREMENT' });
      
      expect(callCount).assertEqual(1);
      expect(receivedState?.count).assertEqual(1);
    });

    it('should provide previous state to subscribers', () => {
      let prevCount = -1;
      let currentCount = -1;
      
      store.subscribe((state, prevState) => {
        currentCount = state.count;
        prevCount = prevState.count;
      });
      
      store.dispatch({ type: 'INCREMENT' });
      
      expect(prevCount).assertEqual(0);
      expect(currentCount).assertEqual(1);
    });

    it('should not notify when state unchanged', () => {
      let callCount = 0;
      store.subscribe(() => { callCount++; });
      
      // 派发不会改变状态的 Action
      store.dispatch({ type: 'UNKNOWN' });
      
      // 初始订阅会触发一次,未知 Action 不应触发
      expect(callCount).assertEqual(1);
    });

    it('should allow unsubscribe', () => {
      let callCount = 0;
      const unsubscribe = store.subscribe(() => { callCount++; });
      
      store.dispatch({ type: 'INCREMENT' });
      expect(callCount).assertEqual(1);
      
      unsubscribe();
      store.dispatch({ type: 'INCREMENT' });
      // 取消订阅后不应再增加
      expect(callCount).assertEqual(1);
    });

    it('should handle multiple subscribers', () => {
      let count1 = 0;
      let count2 = 0;
      
      store.subscribe(() => { count1++; });
      store.subscribe(() => { count2++; });
      
      store.dispatch({ type: 'INCREMENT' });
      
      expect(count1).assertEqual(1);
      expect(count2).assertEqual(1);
    });

    it('should handle subscriber errors gracefully', () => {
      store.subscribe(() => {
        throw new Error('Subscriber error');
      });
      
      // 即使订阅者抛出异常,Store 仍应正常工作
      store.dispatch({ type: 'INCREMENT' });
      expect(store.getState().count).assertEqual(1);
    });
  });

  describe('Replace Reducer', () => {
    it('should replace reducer and recompute state', () => {
      const newReducer = (state: TestState = { count: 0, message: '' }, action: Action): TestState => {
        if (action.type === 'DOUBLE') {
          return { ...state, count: state.count * 2 };
        }
        return state;
      };
      
      store.dispatch({ type: 'INCREMENT' }); // count = 1
      store.replaceReducer(newReducer);
      store.dispatch({ type: 'DOUBLE' });
      
      expect(store.getState().count).assertEqual(2);
    });
  });
});

八、Middleware 测试

Middleware 位于 Action 和 Reducer 之间,测试重点是验证执行顺序、副作用隔离和链式调用。

在这里插入图片描述

8.1 日志中间件测试

// test/middleware/loggerMiddleware.test.ets
import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium';
import { loggerMiddleware } from '../../middleware/loggerMiddleware';

// Mock console
let consoleLogs: string[] = [];
const originalConsoleInfo = console.info;

describe('LoggerMiddleware', () => {
  beforeEach(() => {
    consoleLogs = [];
    console.info = (...args: any[]) => {
      consoleLogs.push(args.join(' '));
    };
  });

  afterEach(() => {
    console.info = originalConsoleInfo;
  });

  it('should log action and state changes', () => {
    const mockStore = {
      getState: () => ({ count: 0 })
    };
    const next = (action: any) => action;
    const action = { type: 'TEST_ACTION', payload: 'test' };
    
    const middleware = loggerMiddleware(mockStore as any);
    middleware(next)(action);
    
    // 验证日志输出包含关键信息
    const logText = consoleLogs.join(' ');
    expect(logText.includes('TEST_ACTION')).assertTrue();
    expect(logText.includes('Prev State')).assertTrue();
    expect(logText.includes('Next State')).assertTrue();
  });

  it('should pass action through to next middleware', () => {
    const mockStore = { getState: () => ({}) };
    let receivedAction: any = null;
    const next = (action: any) => {
      receivedAction = action;
      return action;
    };
    
    const middleware = loggerMiddleware(mockStore as any);
    const result = middleware(next)({ type: 'PASS_THROUGH' });
    
    expect(receivedAction?.type).assertEqual('PASS_THROUGH');
    expect(result?.type).assertEqual('PASS_THROUGH');
  });
});

8.2 Thunk 中间件测试

// test/middleware/thunkMiddleware.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { thunkMiddleware } from '../../middleware/thunkMiddleware';

describe('ThunkMiddleware', () => {
  it('should pass regular action to next', () => {
    const mockStore = { getState: () => ({}), dispatch: (a: any) => a };
    const next = (action: any) => action;
    const regularAction = { type: 'REGULAR' };
    
    const middleware = thunkMiddleware(mockStore as any);
    const result = middleware(next)(regularAction);
    
    expect(result).assertEqual(regularAction);
  });

  it('should execute thunk function', () => {
    const dispatchedActions: any[] = [];
    const mockStore = {
      getState: () => ({ count: 0 }),
      dispatch: (action: any) => {
        dispatchedActions.push(action);
        return action;
      }
    };
    const next = (action: any) => action;
    
    const thunkAction = (dispatch: any, getState: any) => {
      dispatch({ type: 'THUNK_START' });
      const state = getState();
      dispatch({ type: 'THUNK_END', payload: state.count });
    };
    
    const middleware = thunkMiddleware(mockStore as any);
    middleware(next)(thunkAction);
    
    expect(dispatchedActions.length).assertEqual(2);
    expect(dispatchedActions[0].type).assertEqual('THUNK_START');
    expect(dispatchedActions[1].type).assertEqual('THUNK_END');
  });

  it('should pass getState to thunk', () => {
    const mockState = { user: { name: 'test' } };
    const mockStore = {
      getState: () => mockState,
      dispatch: (a: any) => a
    };
    const next = (action: any) => action;
    
    let capturedState: any = null;
    const thunkAction = (dispatch: any, getState: any) => {
      capturedState = getState();
    };
    
    const middleware = thunkMiddleware(mockStore as any);
    middleware(next)(thunkAction);
    
    expect(capturedState).assertEqual(mockState);
  });
});

8.3 持久化中间件测试

// test/middleware/persistMiddleware.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { persistMiddleware } from '../../middleware/persistMiddleware';

// Mock Preferences
let storedData: Map<string, string> = new Map();
const mockPreferences = {
  putSync: (key: string, value: string) => { storedData.set(key, value); },
  flush: () => {}
};

describe('PersistMiddleware', () => {
  beforeEach(() => {
    storedData.clear();
  });

  it('should persist state on cart actions', () => {
    const mockStore = {
      getState: () => ({
        cart: { items: [{ id: '1' }], totalCount: 1 },
        user: { isLogin: true }
      })
    };
    const next = (action: any) => action;
    
    const middleware = persistMiddleware(mockStore as any);
    middleware(next)({ type: 'CART_ADD_ITEM', payload: {} });
    
    expect(storedData.has('redux_state')).assertTrue();
  });

  it('should not persist on non-persist actions', () => {
    const mockStore = { getState: () => ({ cart: {}, user: {} }) };
    const next = (action: any) => action;
    
    const middleware = persistMiddleware(mockStore as any);
    middleware(next)({ type: 'SOME_OTHER_ACTION' });
    
    expect(storedData.has('redux_state')).assertFalse();
  });

  it('should pass action through regardless', () => {
    const mockStore = { getState: () => ({}) };
    const next = (action: any) => action;
    const action = { type: 'CART_ADD_ITEM' };
    
    const middleware = persistMiddleware(mockStore as any);
    const result = middleware(next)(action);
    
    expect(result).assertEqual(action);
  });
});

九、TDD 实践:以 Reducer 为例

在这里插入图片描述

Redux 的纯函数特性使其成为 TDD 的理想实践对象。下面以"购物车折扣计算"功能为例,演示完整的 TDD 流程。

9.1 第一步:编写失败测试

// test/reducers/cartDiscount.test.ets
import { describe, it, expect } from '@ohos/hypium';

describe('Cart Discount Feature (TDD Step 1: Red)', () => {
  it('should apply 10% discount when totalPrice >= 500', () => {
    // 此测试在实现前会失败(Red)
    const initialState = {
      items: [{ id: '1', price: 300, quantity: 2 }], // total = 600
      totalCount: 2,
      totalPrice: 600,
      discountPrice: 0,
      isLoading: false
    };
    
    const newState = cartReducer(initialState, {
      type: 'CART_CALCULATE_DISCOUNT'
    });
    
    expect(newState.discountPrice).assertEqual(540); // 600 * 0.9
  });
});

9.2 第二步:编写最小代码使测试通过

// modules/cart/cartReducer.ets (新增分支)
function cartReducer(state: CartState = initialCartState, action: Action): CartState {
  switch (action.type) {
    // ... 原有分支 ...
    
    case 'CART_CALCULATE_DISCOUNT': {
      const discountRate = state.totalPrice >= 500 ? 0.9 : 1.0;
      return {
        ...state,
        discountPrice: Math.floor(state.totalPrice * discountRate)
      };
    }
    
    default:
      return state;
  }
}

9.3 第三步:重构并补充边界测试

// test/reducers/cartDiscount.test.ets (补充)
describe('Cart Discount Feature (TDD Step 3: Refactor)', () => {
  it('should not apply discount when totalPrice < 500', () => {
    const initialState = {
      items: [{ id: '1', price: 100, quantity: 1 }],
      totalCount: 1,
      totalPrice: 100,
      discountPrice: 0,
      isLoading: false
    };
    
    const newState = cartReducer(initialState, {
      type: 'CART_CALCULATE_DISCOUNT'
    });
    
    expect(newState.discountPrice).assertEqual(100);
  });

  it('should apply discount at exact boundary 500', () => {
    const initialState = {
      items: [{ id: '1', price: 500, quantity: 1 }],
      totalCount: 1,
      totalPrice: 500,
      discountPrice: 0,
      isLoading: false
    };
    
    const newState = cartReducer(initialState, {
      type: 'CART_CALCULATE_DISCOUNT'
    });
    
    expect(newState.discountPrice).assertEqual(450); // 500 * 0.9
  });
});

十、测试运行与 CI/CD 集成

10.1 本地运行测试

在 DevEco Studio 中,右键点击 test 目录选择 Run Tests,或在终端执行:

# 运行所有单元测试
hdc shell am instrument -w com.example.myapplication.test/TestRunner

# 运行指定测试文件
hdc shell am instrument -w -e class com.example.CartReducerTest \
  com.example.myapplication.test/TestRunner

10.2 测试覆盖率报告

配置 ohosTest 模块的 build-profile.json5 开启覆盖率采集:

{
  "module": {
    "testRunner": "@ohos/hypium",
    "coverage": true
  }
}

运行测试后,在 build/default/reports/coverage 目录下生成 HTML 报告,可直观查看每个文件、每行代码的覆盖情况。

10.3 CI/CD 流水线集成

build.yml 中添加测试阶段:

# .github/workflows/build.yml
name: HarmonyOS CI

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup HarmonyOS SDK
        uses: harmonyos/setup-sdk@v1
        
      - name: Run Unit Tests
        run: |
          hvigorw test --no-daemon
          
      - name: Upload Coverage
        uses: codecov/codecov-action@v4
        with:
          files: build/default/reports/coverage/report.xml
          fail_ci_if_error: true

十一、常见问题与解决方案

11.1 问题一:测试间状态污染

现象:测试 A 修改了 Store 状态,测试 B 运行时继承了脏状态。

解决:每个测试用例的 beforeEach 中重置 Store 实例:

beforeEach(() => {
  ReduxStore.resetInstance();
  store = ReduxStore.getInstance(testReducer);
});

11.2 问题二:异步测试超时

现象:涉及 setTimeout 或 Promise 的测试偶尔超时失败。

解决:使用 async/await 替代回调,并设置合理的超时时间:

it('should complete async operation', async () => {
  await store.dispatch(asyncAction());
  expect(store.getState().loaded).assertTrue();
}, 5000); // 5秒超时

11.3 问题三:Mock 未生效

现象:Mock 了 console.info 但日志仍输出到控制台。

解决:确保 Mock 在测试开始前完成,并在 afterEach 中恢复:

let originalConsole: any;
beforeEach(() => {
  originalConsole = console.info;
  console.info = () => {};
});
afterEach(() => {
  console.info = originalConsole;
});

11.4 问题四:浮点数精度断言失败

现象expect(0.1 + 0.2).assertEqual(0.3) 失败。

解决:使用容差比较或整数化:

// 方案一:容差比较
expect(Math.abs(result - expected) < 0.001).assertTrue();

// 方案二:整数化(推荐用于金额)
expect(Math.floor(result * 100)).assertEqual(Math.floor(expected * 100));

十二、总结

本文从 HarmonyOS 单元测试框架出发,系统性地覆盖了 Redux 风格状态管理五大核心模块的测试方法:

测试对象 核心关注点 推荐覆盖率
Action 参数校验、返回值结构、异常抛出 100%
Reducer 状态转换、不可变性、边界条件 100%
Selector 输入映射、缓存逻辑、派生计算 95%+
Store 订阅通知、Dispatch 流程、单例行为 90%+
Middleware 执行顺序、副作用隔离、链式调用 85%+

Redux 架构的纯函数本质赋予了它卓越的"可测试性"——给定确定的输入,永远产生确定的输出,无副作用,无隐藏状态。这一特性使得 TDD(测试驱动开发)在状态管理领域不仅可行,而且高效。通过本文提供的测试代码模板和最佳实践,开发者可以在 HarmonyOS 项目中建立高置信度的状态管理测试体系,让每一次代码提交都经过充分验证,让每一次状态变更都可追溯、可回滚。


转载自:https://blog.csdn.net/u014727709/article/details/163537660
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐