iOS Redux 单元测试全面指南

117 阅读4分钟

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. 测试金字塔实践

  1. 大量 Action 和 Reducer 测试 (基础单元测试)
  2. 适量 Middleware 测试 (集成测试)
  3. 少量 Store 和 ViewModel 测试 (高层测试)

10. 常见测试陷阱与解决方案

问题1: 测试依赖实际网络请求

解决方案:

  • 使用依赖注入 mock 服务
  • 使用 OHHTTPStubs 等库拦截网络请求

问题2: 测试中状态污染

解决方案:

  • 每个测试用例前重置 store 状态
  • 使用 setUp() 方法初始化干净环境

问题3: 异步测试不稳定

解决方案:

  • 使用 XCTestExpectation 正确等待
  • 设置合理的超时时间
  • 避免使用 sleep(),用回调或通知机制

问题4: 测试过于脆弱

解决方案:

  • 测试行为而非实现细节
  • 避免过度测试内部状态
  • 使用黑盒测试方法

通过以上测试策略,可以全面覆盖 Redux 架构的各个部分,确保应用的行为符合预期,同时保持代码的可维护性和可扩展性。

from: deepseek