HarmonyOS 状态管理单元测试:构建高置信度的 Redux 风格测试体系
文章目录

每日一句正能量
“决定牺牲自己成全别人时,要看这件事是否值得,因为你的时间和精力同样值钱。”
你的善良必须带点锋芒,牺牲不是美德,把资源投在值得的地方才是。在把自己烧光之前,先问问对方值不值得你剪下这一截烛芯。
摘要
摘要:上一篇文章我们搭建了一套基于 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 作为官方单元测试框架,支持以下核心能力:
- 断言库:
assertEqual、assertTrue、assertThrowError等丰富断言。 - 异步测试:
async/await配合done回调,支持 Promise 和回调式异步代码测试。 - Mock 能力:通过
mock函数替换模块依赖,隔离外部系统 API。 - 参数化测试:
it支持数组参数批量执行同一测试逻辑。 - 测试生命周期:
beforeAll、beforeEach、afterAll、afterEach管理测试环境。
三、测试覆盖范围与策略

上图展示了 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 测试最佳实践
- 使用工厂函数创建测试数据:避免每个测试用例重复构造复杂的初始状态。
- 验证不可变性:每次状态变更后,断言原始状态未被修改。
- 覆盖边界条件:空数组、空字符串、极大/极小值、null/undefined。
- 测试未知 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
欢迎 👍点赞✍评论⭐收藏,欢迎指正
更多推荐



所有评论(0)