Testing

Reducers

Reducers are pure functions, which can easily be tested by providing a state and action, and asserting against the resulting state:

// Source:
static func reducer(state: State, action: Action) -> State {
    switch action {
        case _ as HomeScore:
            return State(home: state.home + 1, away: state.away)
        case _ as AwayScore:
            return State(home: state.home, away: state.away + 1)
        case _ as ResetScore:
            return State(home: 0, away: 0)
        default:
            return state
    }
}

// Tests:
let mockState = Scoreboard.State(home: 2, away: 1)

func testReducer_HomeScoreAction_IncrementsHomeState() {
    let expect = Scoreboard.State(home: 3, away: 1)
    let result = Scoreboard.reducer(state: mockState, action: Scoreboard.HomeScore())
    XCTAssertEqual(expect, result)
}

func testReducer_AwayScoreAction_IncrementsAwayState() {
    let expect = Scoreboard.State(home: 2, away: 2)
    let result = Scoreboard.reducer(state: mockState, action: Scoreboard.AwayScore())
    XCTAssertEqual(expect, result)
}

func testReducer_ResetScoreAction_ResetsState() {
    let expect = Scoreboard.State(home: 0, away: 0)
    let result = Scoreboard.reducer(state: mockState, action: Scoreboard.ResetScore())
    XCTAssertEqual(expect, result)
}

Selectors

Selectors are pure functions, which can easily be tested by providing a state, and asserting against the computed value:

// Source:
struct State: Equatable {
    let subtotal: Decimal
    let taxRate: Decimal
}

let getSubtotal = { (state: State) in state.subtotal }
let getTaxRate = { (state: State) in state.taxRate }

let getTotalCost = createSelector(
    getSubtotal,
    getTaxRate,
    transformation: { subtotal, taxRate -> Decimal in
        let total = subtotal * (1 + taxRate)
        return total
    }
)

// Tests:
func testTotalCostSelector() {
    let mockState = State(subtotal: 25.0, taxRate: 0.05)
    let result = getTotalCost(mockState)
    XCTAssertEqual(26.25, result)
}

Effects

Effects can be tested by observing how it’s source responds to emitted actions.

The following example Effect calls an API to post scores:

// Effect Definition

static let postScore = Effect(dispatch: true) { actions in
    actions
        .ofType(PostScore.self)
        .flatMap(getPostAPI)
        .eraseToAnyPublisher()
}

// API Management

static var apiManager: ScoreAPIManager = URLSession.shared

static func getPostAPI(action: PostScore) -> AnyPublisher<Action, Never> {
    return apiManager.postScore(home: action.home, away: action.away)
        .map({ _ in PostScoreSuccess() })
        .replaceError(with: PostScoreError())
        .eraseToAnyPublisher()
}

protocol ScoreAPIManager {
    func postScore(home: String, away: String) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
}

extension URLSession: ScoreAPIManager {
    func postScore(home: String, away: String) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")
        var urlRequest = URLRequest(url: url!)
        urlRequest.httpMethod = "POST"
        return dataTaskPublisher(for: urlRequest).eraseToAnyPublisher()
    }
}

The following tests create a mock ScoreAPIManager (defined above) to assert the results of the Effect based on the API result:

func testPostScoreEffect_OnRequestSuccess_DispatchPostScoreSuccess() {
    Scoreboard.apiManager = MockSuccessScoreAPIManager()
    let actions = PassthroughSubject<Action, Never>()

    let expectationReceiveAction = expectation(description: "receiveAction")
    cancellable = Scoreboard.postScore.source(actions.eraseToAnyPublisher()).sink { resultAction in
        XCTAssertTrue(resultAction is Scoreboard.PostScoreSuccess)
        expectationReceiveAction.fulfill()
    }

    actions.send(Scoreboard.PostScore(home: "0", away: "0"))

    wait(for: [expectationReceiveAction], timeout: 10.0)
}

func testPostScoreEffect_OnRequestFailure_DispatchPostScoreSuccess() {
    Scoreboard.apiManager = MockFailureScoreAPIManager()
    let actions = PassthroughSubject<Action, Never>()

    let expectationReceiveAction = expectation(description: "receiveAction")
    cancellable = Scoreboard.postScore.source(actions.eraseToAnyPublisher()).sink { resultAction in
        XCTAssertTrue(resultAction is Scoreboard.PostScoreError)
        expectationReceiveAction.fulfill()
    }

    actions.send(Scoreboard.PostScore(home: "0", away: "0"))

    wait(for: [expectationReceiveAction], timeout: 10.0)
}

// API Mocks

class MockSuccessScoreAPIManager: ScoreAPIManager {
    func postScore(home: String, away: String) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> {
        let output: URLSession.DataTaskPublisher.Output = (data: Data(), response: URLResponse())
        return Just(output).setFailureType(to: URLSession.DataTaskPublisher.Failure.self).eraseToAnyPublisher()
    }
}

class MockFailureScoreAPIManager: ScoreAPIManager {
    func postScore(home: String, away: String) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure> {
        return Fail(outputType: URLSession.DataTaskPublisher.Output.self, failure: URLSession.DataTaskPublisher.Failure(.badURL)).eraseToAnyPublisher()
    }
}

Store-dependent types

Types that depend on a Store instance can use MockStore from the ReCombineTest module for unit testing. See the installation page for how to add the ReCombineTest module.

The following view model is used in the example project to show a scoreboard view.

class ScoreboardViewModel: ObservableObject {
    @Published var homeScore = ""
    @Published var awayScore = ""
    @Published var showAlert = false
    private let store: Store<Scoreboard.State>
    private var cancellableSet: Set<AnyCancellable> = []

    init(store: Store<Scoreboard.State> = appStore) {
        self.store = store

        // Bind selectors
        store.select(Scoreboard.getHomeScoreString).assign(to: \.homeScore, on: self).store(in: &cancellableSet)
        store.select(Scoreboard.getAwayScoreString).assign(to: \.awayScore, on: self).store(in: &cancellableSet)

        // Register PostScoreSuccess Effect
        let showAlert = Effect(dispatch: true) { actions in
            actions.ofType(Scoreboard.PostScoreSuccess.self)
                .receive(on: RunLoop.main)
                .handleEvents(receiveOutput: { [weak self] _ in
                    self?.showAlert = true
                })
                .map { _ in Scoreboard.ResetScore() }
                .eraseActionType()
                .eraseToAnyPublisher()
        }
        store.register(showAlert).store(in: &cancellableSet)
    }

    func homeScoreTapped() {
        store.dispatch(action: Scoreboard.HomeScore())        
    }

    func awayScoreTapped() {
        store.dispatch(action: Scoreboard.AwayScore())
    }

    func postScoreTapped() {
        store.dispatch(action: Scoreboard.PostScore(home: homeScore, away: awayScore))
    }
}

The following test class shows testing property bindings, local effects, and action dispatching functions:

import Combine
@testable import ReCombine_Scoreboard
import ReCombineTest
import XCTest

class ScoreboardViewModelTests: XCTestCase {
    var mockStore: MockStore<Scoreboard.State>!
    var vm: ScoreboardViewModel!
    var cancellableSet: Set<AnyCancellable> = []

    override func setUp() {
        mockStore = MockStore(state: Scoreboard.State())
        vm = ScoreboardViewModel(store: mockStore)
    }

    override func tearDown() {
        cancellableSet = []
    }

    // MARK: - Property bindings

    func testPropertyBindings() {
        let expectationReceiveHomeScore = expectation(description: "receiveHomeScore")
        vm.$homeScore.sink { score in
            XCTAssertEqual("0", score)
            expectationReceiveHomeScore.fulfill()
        }.store(in: &cancellableSet)

        let expectationReceiveAwayScore = expectation(description: "receiveAwayScore")
        vm.$awayScore.sink { score in
            XCTAssertEqual("0", score)
            expectationReceiveAwayScore.fulfill()
        }.store(in: &cancellableSet)

        wait(for: [expectationReceiveHomeScore, expectationReceiveAwayScore], timeout: 10.0)
    }

    // MARK: - showAlert Effect

    func testShowAlert_UpdatesShowAlert_OnPostScoreSuccess() {
        let expectationReceiveValues = expectation(description: "receiveValue")
        vm.$showAlert.collect(2).sink { showAlertValues in
            guard let firstAlertValue = showAlertValues.first,
                let secondAlertValue = showAlertValues.last else { return XCTFail() }
            XCTAssertFalse(firstAlertValue)
            XCTAssertTrue(secondAlertValue)
            expectationReceiveValues.fulfill()
        }.store(in: &cancellableSet)

        let expectationReceiveActions = expectation(description: "receiveAction")
        mockStore.dispatchedActions.collect(2).sink { actions in
            guard let firstAction = actions.first,
                let secondAction = actions.last else { return XCTFail() }
            XCTAssertTrue(firstAction is Scoreboard.PostScoreSuccess)
            XCTAssertTrue(secondAction is Scoreboard.ResetScore)
            expectationReceiveActions.fulfill()
        }.store(in: &cancellableSet)

        mockStore.dispatch(action: Scoreboard.PostScoreSuccess())

        wait(for: [expectationReceiveValues, expectationReceiveActions], timeout: 10.0)
    }

    // MARK: - Action dispatching functions

    func testHomeScoreTapped_DispatchesHomeScoreAction() {
        let expectationReceiveAction = expectation(description: "receiveAction")
        mockStore.dispatchedActions.sink { action in
            XCTAssertTrue(action is Scoreboard.HomeScore)
            expectationReceiveAction.fulfill()
        }.store(in: &cancellableSet)

        vm.homeScoreTapped()

        wait(for: [expectationReceiveAction], timeout: 10.0)
    }

    func testAwayScoreTapped_DispatchesAwayScoreAction() {
        let expectationReceiveAction = expectation(description: "receiveAction")
        mockStore.dispatchedActions.sink { action in
            XCTAssertTrue(action is Scoreboard.AwayScore)
            expectationReceiveAction.fulfill()
        }.store(in: &cancellableSet)

        vm.awayScoreTapped()

        wait(for: [expectationReceiveAction], timeout: 10.0)
    }

    func testPostScoreTapped_DispatchesPostScoreAction() {
        let expectationReceiveAction = expectation(description: "receiveAction")
        mockStore.dispatchedActions.sink { action in
            XCTAssertTrue(action is Scoreboard.PostScore)
            expectationReceiveAction.fulfill()
        }.store(in: &cancellableSet)

        vm.postScoreTapped()

        wait(for: [expectationReceiveAction], timeout: 10.0)
    }
}