iOS Redux 单元测试全面指南
Redux 架构的单向数据流特性使其非常适合单元测试。下面我将详细介绍如何对 Redux 的各个部分进行单元测试。
1. Action 测试
测试 Action 主要是验证其类型和关联值是否正确。
// 定义 Action
enum CounterAction: Action {
case increment
case decrement
case setValue(Int)
}
// Action 测试
class ActionTests: XCTestCase {
func testActionTypes() {
let increment = CounterAction.increment
let decrement = CounterAction.decrement
let setValue = CounterAction.setValue(10)
if case .increment = increment {
// 测试通过
} else {
XCTFail("Incorrect action type")
}
if case .setValue(let value) = setValue {
XCTAssertEqual(value, 10)
} else {
XCTFail("Incorrect action type")
}
}
}
2. State 测试
测试 State 主要是验证其初始状态和 Equatable 实现。
// 定义 State
struct AppState: State, Equatable {
var counter: Int = 0
var isLoading: Bool = false
}
// State 测试
class StateTests: XCTestCase {
func testInitialState() {
let state = AppState()
XCTAssertEqual(state.counter, 0)
XCTAssertFalse(state.isLoading)
}
func testStateEquality() {
let state1 = AppState(counter: 1, isLoading: true)
let state2 = AppState(counter: 1, isLoading: true)
let state3 = AppState(counter: 2, isLoading: false)
XCTAssertEqual(state1, state2)
XCTAssertNotEqual(state1, state3)
}
}
3. Reducer 测试
Reducer 是纯函数,测试时只需验证给定输入是否产生预期输出。
基础 Reducer 测试
// 定义 Reducer
func counterReducer(state: AppState, action: Action) -> AppState {
var state = state
switch action {
case let action as CounterAction:
switch action {
case .increment:
state.counter += 1
case .decrement:
state.counter -= 1
case .setValue(let value):
state.counter = value
}
default:
break
}
return state
}
// Reducer 测试
class ReducerTests: XCTestCase {
func testCounterReducer() {
let initialState = AppState()
// 测试 increment
let stateAfterIncrement = counterReducer(state: initialState, action: CounterAction.increment)
XCTAssertEqual(stateAfterIncrement.counter, 1)
// 测试 decrement
let stateAfterDecrement = counterReducer(state: initialState, action: CounterAction.decrement)
XCTAssertEqual(stateAfterDecrement.counter, -1)
// 测试 setValue
let stateAfterSet = counterReducer(state: initialState, action: CounterAction.setValue(5))
XCTAssertEqual(stateAfterSet.counter, 5)
}
}
组合 Reducer 测试
class CombinedReducerTests: XCTestCase {
func testCombinedReducer() {
let initialState = AppState()
let reducers: [Reducer] = [counterReducer, loadingReducer]
// 创建组合 reducer
let combinedReducer = combineReducers(reducers)
// 测试组合效果
let action = CounterAction.increment
let newState = combinedReducer(initialState, action)
XCTAssertEqual(newState.counter, 1)
}
}
4. Middleware 测试
Middleware 测试需要验证其对 action 的处理逻辑。
// 定义 Middleware
func loggerMiddleware(store: Store) -> (Action) -> Action {
return { next in
return { action in
print("Dispatching: \(action)")
return next(action)
}
}
}
// Middleware 测试
class MiddlewareTests: XCTestCase {
func testLoggerMiddleware() {
// 创建 mock store
let mockStore = MockStore()
// 创建 middleware
let middleware = loggerMiddleware(store: mockStore)
// 测试 action 传递
let action = CounterAction.increment
let nextCalledExpectation = expectation(description: "Next called")
let next: (Action) -> Action = { action in
nextCalledExpectation.fulfill()
return action
}
_ = middleware(next)(action)
waitForExpectations(timeout: 1, handler: nil)
}
}
class MockStore: StoreProtocol {
var state: State = AppState()
func dispatch(action: Action) {}
func subscribe(_ subscriber: AnyStoreSubscriber) {}
func unsubscribe(_ subscriber: AnyStoreSubscriber) {}
}
5. 异步 Action 测试
测试异步 Action 需要验证其是否正确派发了预期的 actions。
// 定义异步 Action
struct FetchDataAction: AsyncAction {
func execute(dispatch: @escaping (Action) -> Void, state: () -> State?) {
DispatchQueue.global().async {
// 模拟网络请求
sleep(1)
dispatch(DataFetchedAction(data: "Test Data"))
}
}
}
// 异步 Action 测试
class AsyncActionTests: XCTestCase {
func testFetchDataAction() {
let expectation = XCTestExpectation(description: "Async action completed")
let action = FetchDataAction()
var receivedAction: Action?
let dispatch: (Action) -> Void = { action in
receivedAction = action
expectation.fulfill()
}
action.execute(dispatch: dispatch, state: { nil })
wait(for: [expectation], timeout: 2)
guard let dataAction = receivedAction as? DataFetchedAction else {
XCTFail("Expected DataFetchedAction")
return
}
XCTAssertEqual(dataAction.data, "Test Data")
}
}
6. Store 测试
测试 Store 主要是验证其能否正确管理状态和通知订阅者。
class StoreTests: XCTestCase {
func testStoreDispatch() {
let initialState = AppState()
let store = Store(reducer: counterReducer, state: initialState)
// 订阅状态变化
let expectation = XCTestExpectation(description: "State updated")
var newState: AppState?
store.subscribe(AnyStoreSubscriber(
onStateChange: { state in
newState = state as? AppState
expectation.fulfill()
}
))
// 派发 action
store.dispatch(CounterAction.increment)
wait(for: [expectation], timeout: 1)
XCTAssertEqual(newState?.counter, 1)
}
func testStoreMiddleware() {
let initialState = AppState()
let middleware: Middleware = { store, next in
return { action in
print("Middleware processing: \(action)")
return next(action)
}
}
let store = Store(
reducer: counterReducer,
state: initialState,
middleware: [middleware]
)
// 测试 middleware 是否被调用
let expectation = XCTestExpectation(description: "Middleware called")
store.dispatch(CounterAction.increment) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
}
}
7. ViewModel/Presenter 测试
测试与 Redux 集成的 ViewModel/Presenter。
class CounterViewModelTests: XCTestCase {
var store: MockStore!
var viewModel: CounterViewModel!
override func setUp() {
super.setUp()
store = MockStore()
viewModel = CounterViewModel(store: store)
}
func testIncrement() {
viewModel.increment()
XCTAssertEqual(store.dispatchedActions.count, 1)
if case .some(CounterAction.increment) = store.dispatchedActions.first {
// 测试通过
} else {
XCTFail("Expected increment action")
}
}
func testStateObservation() {
let expectation = XCTestExpectation(description: "State updated")
viewModel.onCounterChange = { value in
XCTAssertEqual(value, 5)
expectation.fulfill()
}
// 模拟 store 状态变化
store.state = AppState(counter: 5)
store.notifySubscribers()
wait(for: [expectation], timeout: 1)
}
}
class MockStore: StoreProtocol {
var state: State = AppState()
var dispatchedActions: [Action] = []
var subscribers: [AnyStoreSubscriber] = []
func dispatch(action: Action) {
dispatchedActions.append(action)
}
func subscribe(_ subscriber: AnyStoreSubscriber) {
subscribers.append(subscriber)
}
func unsubscribe(_ subscriber: AnyStoreSubscriber) {}
func notifySubscribers() {
subscribers.forEach { $0.onStateChange(state) }
}
}
8. 测试工具和技巧
8.1 测试辅助函数
extension XCTestCase {
func expectAction<A: Action>(_ actionMatcher: @escaping (A) -> Bool) -> (Action) -> Void {
let expectation = self.expectation(description: "Action received")
return { action in
if let action = action as? A, actionMatcher(action) {
expectation.fulfill()
}
}
}
func expectActions<A: Action>(_ actions: [A], in order: Bool = true) -> (Action) -> Void {
var expectedActions = actions
let expectation = self.expectation(description: "All actions received")
expectation.expectedFulfillmentCount = actions.count
return { action in
guard let receivedAction = action as? A else { return }
if order {
if receivedAction == expectedActions.first {
expectedActions.removeFirst()
expectation.fulfill()
}
} else {
if let index = expectedActions.firstIndex(of: receivedAction) {
expectedActions.remove(at: index)
expectation.fulfill()
}
}
}
}
}
// 使用示例
func testMultipleActions() {
let actionSpy = expectActions([
CounterAction.increment,
CounterAction.setValue(10)
])
let store = TestStore(reducer: counterReducer, state: AppState())
store.dispatch(CounterAction.increment)
store.dispatch(CounterAction.setValue(10))
waitForExpectations(timeout: 1, handler: nil)
}
8.2 测试异步操作
func testAsyncOperation() {
let store = TestStore(reducer: counterReducer, state: AppState())
let action = FetchDataAction()
let expectation = self.expectation(description: "Async operation completed")
store.dispatch(action) {
XCTAssertEqual(store.state.counter, 1)
expectation.fulfill()
}
waitForExpectations(timeout: 2, handler: nil)
}
9. 测试金字塔实践
- 大量 Action 和 Reducer 测试 (基础单元测试)
- 适量 Middleware 测试 (集成测试)
- 少量 Store 和 ViewModel 测试 (高层测试)
10. 常见测试陷阱与解决方案
问题1: 测试依赖实际网络请求
解决方案:
- 使用依赖注入 mock 服务
- 使用 OHHTTPStubs 等库拦截网络请求
问题2: 测试中状态污染
解决方案:
- 每个测试用例前重置 store 状态
- 使用
setUp()方法初始化干净环境
问题3: 异步测试不稳定
解决方案:
- 使用
XCTestExpectation正确等待 - 设置合理的超时时间
- 避免使用
sleep(),用回调或通知机制
问题4: 测试过于脆弱
解决方案:
- 测试行为而非实现细节
- 避免过度测试内部状态
- 使用黑盒测试方法
通过以上测试策略,可以全面覆盖 Redux 架构的各个部分,确保应用的行为符合预期,同时保持代码的可维护性和可扩展性。
from: deepseek