Redux 调试与测试
在 Bilup 中调试和测试 Redux 状态需要理解 store 结构、有效使用开发工具以及实现全面的测试覆盖。
开发工具
Redux DevTools
通过浏览器扩展访问 Redux DevTools:
// 使用 DevTools 设置 Store
const configureStore = (initialState) => {
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
return createStore(
rootReducer,
initialState,
composeEnhancers(applyMiddleware(thunk))
);
};
DevTools 功能:
- 时间回溯: 逐步浏览动作历史
- 状态检查: 查看当前和历史状态
- 动作重放: 从任意点重放动作
- 状态导入/导出: 保存和加载状态快照
浏览器控制台调试
将调试辅助函数添加到 window 对象:
// 添加到浏览器控制台用于调试
window.inspectReduxState = () => {
const state = store.getState();
console.log('Redux State:', state);
return state;
};
window.dispatchAction = (action) => {
store.dispatch(action);
console.log('Dispatched:', action);
};
// 检查特定状态片段
window.getProjectState = () => store.getState().projectState;
window.getVMState = () => store.getState().vm;
window.getGUIState = () => store.getState().gui;
状态检查工具
// 开发工具
const createStateInspector = (store) => {
return {
// 获取当前状态
getState: () => store.getState(),
// 获取特定状态片段
getSlice: (path) => {
const state = store.getState();
return path.split('.').reduce((obj, key) => obj?.[key], state);
},
// 按谓词查找状态
findInState: (predicate, obj = store.getState(), path = '') => {
const results = [];
Object.entries(obj).forEach(([key, value]) => {
const currentPath = path ? `${path}.${key}` : key;
if (predicate(value, key, currentPath)) {
results.push({ path: currentPath, value });
}
if (typeof value === 'object' && value !== null) {
results.push(...this.findInState(predicate, value, currentPath));
}
});
return results;
},
// 监视状态变化
watchState: (path, callback) => {
let lastValue = this.getSlice(path);
return store.subscribe(() => {
const currentValue = this.getSlice(path);
if (currentValue !== lastValue) {
callback(currentValue, lastValue);
lastValue = currentValue;
}
});
}
};
};
// 使用
const inspector = createStateInspector(store);
inspector.watchState('projectState.saveState', (current, previous) => {
console.log(`保存状态已更改: ${previous} → ${current}`);
});
测试策略
Reducer 测试
独立测试 reducers:
import projectStateReducer from '../reducers/project-state';
describe('projectStateReducer', () => {
const initialState = {
projectId: null,
projectTitle: '',
isLoading: false,
error: null,
saveState: 'NOT_SAVED'
};
test('should handle SET_PROJECT_ID', () => {
const action = { type: 'SET_PROJECT_ID', projectId: '123456789' };
const newState = projectStateReducer(initialState, action);
expect(newState.projectId).toBe('123456789');
expect(newState).not.toBe(initialState); // 不可变性检查
});
test('should handle SET_PROJECT_LOADING', () => {
const stateWithError = { ...initialState, error: 'Previous error' };
const action = { type: 'SET_PROJECT_LOADING', isLoading: true };
const newState = projectStateReducer(stateWithError, action);
expect(newState.isLoading).toBe(true);
expect(newState.error).toBe(null); // 错误应该被清除
});
test('should return current state for unknown actions', () => {
const action = { type: 'UNKNOWN_ACTION' };
const newState = projectStateReducer(initialState, action);
expect(newState).toBe(initialState);
});
});
Action Creator 测试
import { setProjectTitle, loadProject } from '../actions/project-actions';
describe('Project Actions', () => {
test('setProjectTitle should create correct action', () => {
const title = 'My Project';
const expectedAction = {
type: 'SET_PROJECT_TITLE',
title
};
expect(setProjectTitle(title)).toEqual(expectedAction);
});
test('loadProject should handle async loading', async () => {
const mockDispatch = jest.fn();
const mockGetState = jest.fn();
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: '123', title: 'Test Project' })
})
);
const thunk = loadProject('123');
await thunk(mockDispatch, mockGetState);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_PROJECT_LOADING',
isLoading: true
});
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_PROJECT',
project: { id: '123', title: 'Test Project' }
});
});
});
Store 集成测试
测试完整的 store 行为:
import configureStore from '../store';
describe('Redux Store Integration', () => {
let store;
beforeEach(() => {
store = configureStore();
});
test('should initialize with correct default state', () => {
const state = store.getState();
expect(state.projectState.projectId).toBe(null);
expect(state.gui.theme.theme).toBe('light');
expect(state.vm.targets).toEqual([]);
});
test('should handle action dispatch workflow', () => {
// 设置项目 ID
store.dispatch({
type: 'SET_PROJECT_ID',
projectId: '123456789'
});
let state = store.getState();
expect(state.projectState.projectId).toBe('123456789');
// 设置项目标题
store.dispatch({
type: 'SET_PROJECT_TITLE',
title: 'My Project'
});
state = store.getState();
expect(state.projectState.projectTitle).toBe('My Project');
});
test('should handle multiple state updates', () => {
const actions = [
{ type: 'SET_PROJECT_ID', projectId: '123' },
{ type: 'SET_PROJECT_TITLE', title: 'Test' },
{ type: 'SET_THEME', theme: 'dark' },
{ type: 'SET_STAGE_SIZE', stageSize: 'small' }
];
actions.forEach(action => store.dispatch(action));
const state = store.getState();
expect(state.projectState.projectId).toBe('123');
expect(state.projectState.projectTitle).toBe('Test');
expect(state.gui.theme.theme).toBe('dark');
expect(state.gui.stage.stageSize).toBe('small');
});
});
Selector 测试
import {
getProjectStatus,
getIsProjectLoaded,
getSpriteCount
} from '../selectors';
describe('Selectors', () => {
const mockState = {
projectState: {
projectId: '123',
isLoading: false,
saveState: 'NOT_SAVED'
},
vm: {
targets: [
{ id: 'stage', isStage: true },
{ id: 'sprite1', isStage: false },
{ id: 'sprite2', isStage: false }
]
}
};
test('getIsProjectLoaded should return correct value', () => {
expect(getIsProjectLoaded(mockState)).toBe(true);
const loadingState = {
...mockState,
projectState: { ...mockState.projectState, isLoading: true }
};
expect(getIsProjectLoaded(loadingState)).toBe(false);
});
test('getProjectStatus should return correct status', () => {
expect(getProjectStatus(mockState)).toBe('unsaved');
const savedState = {
...mockState,
projectState: { ...mockState.projectState, saveState: 'SAVED' }
};
expect(getProjectStatus(savedState)).toBe('saved');
});
test('getSpriteCount should count non-stage targets', () => {
expect(getSpriteCount(mockState)).toBe(2);
});
});
错误调试
Redux 错误边界
class ReduxErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Redux Error:', error);
console.error('Error Info:', errorInfo);
// 记录到错误跟踪服务
if (window.errorTracker) {
window.errorTracker.captureException(error, {
context: 'Redux State',
extra: errorInfo
});
}
}
render() {
if (this.state.hasError) {
return (
<div className="error-boundary">
<h2>应用程序状态出现问题</h2>
<details>
<summary>错误详情</summary>
<pre>{this.state.error?.stack}</pre>
</details>
<button onClick={() => window.location.reload()}>
重新加载应用程序
</button>
</div>
);
}
return this.props.children;
}
}
状态验证
// 状态形状验证
const validateState = (state) => {
const errors = [];
// 验证项目状态
if (state.projectState.projectId && typeof state.projectState.projectId !== 'string') {
errors.push('projectState.projectId must be string or null');
}
// 验证 VM 状态
if (!Array.isArray(state.vm.targets)) {
errors.push('vm.targets must be an array');
}
// 验证 GUI 状态
const validThemes = ['light', 'dark', 'high-contrast'];
if (!validThemes.includes(state.gui.theme.theme)) {
errors.push(`gui.theme.theme must be one of: ${validThemes.join(', ')}`);
}
return errors;
};
// 在开发环境中使用
if (process.env.NODE_ENV === 'development') {
store.subscribe(() => {
const errors = validateState(store.getState());
if (errors.length > 0) {
console.error('State validation errors:', errors);
}
});
}
性能调试
动作性能监控
const performanceDebugger = (store) => {
const actionTimes = new Map();
const slowActions = [];
return {
startTimer: (actionType) => {
actionTimes.set(actionType, performance.now());
},
endTimer: (actionType) => {
const startTime = actionTimes.get(actionType);
if (startTime) {
const duration = performance.now() - startTime;
actionTimes.delete(actionType);
if (duration > 10) {
slowActions.push({ action: actionType, duration });
console.warn(`Slow action: ${actionType} took ${duration.toFixed(2)}ms`);
}
return duration;
}
},
getSlowActions: () => slowActions,
reset: () => {
actionTimes.clear();
slowActions.length = 0;
}
};
};
内存使用跟踪
const memoryTracker = (store) => {
const measurements = [];
return {
measure: (label) => {
const state = store.getState();
const stateSize = JSON.stringify(state).length;
measurements.push({
label,
timestamp: Date.now(),
stateSize,
heapUsed: performance.memory?.usedJSHeapSize || 0
});
},
getReport: () => {
return measurements.map((m, i) => {
const prev = measurements[i - 1];
return {
...m,
stateDelta: prev ? m.stateSize - prev.stateSize : 0,
heapDelta: prev ? m.heapUsed - prev.heapUsed : 0
};
});
}
};
};
调试常见问题
状态未更新
// 检查 reducer 是否处理动作
const debugReducer = (reducer) => (state, action) => {
console.log(`Reducer called with action: ${action.type}`);
console.log('Previous state:', state);
const newState = reducer(state, action);
console.log('New state:', newState);
console.log('State changed:', newState !== state);
return newState;
};
动作未调用
// 包装 dispatch 进行调试
const debugDispatch = (originalDispatch) => (action) => {
console.log('Dispatching action:', action);
try {
const result = originalDispatch(action);
console.log('Action dispatched successfully');
return result;
} catch (error) {
console.error('Error dispatching action:', error);
throw error;
}
};
store.dispatch = debugDispatch(store.dispatch);
组件未重新渲染
// 调试 useSelector
const useDebugSelector = (selector, equalityFn) => {
const selected = useSelector(selector, equalityFn);
useEffect(() => {
console.log('Selector result changed:', selected);
}, [selected]);
return selected;
};
Bilup 中的 Redux 调试需要系统化的 测试、监控和错误处理方法,以确保整个应用程序的可靠状态管理。