Skip to content

Commit

Permalink
Make tests more solid by awaiting particular states too.
Browse files Browse the repository at this point in the history
  • Loading branch information
pixlwave committed Mar 25, 2024
1 parent 606aef5 commit 85826d7
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.eraseToAnyPublisher()
}

/// For testing purposes.
var statePublisher: AnyPublisher<UserSessionFlowCoordinatorStateMachine.State, Never> { stateMachine.statePublisher }

init(userSession: UserSessionProtocol,
navigationRootCoordinator: NavigationRootCoordinator,
windowManager: WindowManagerProtocol,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//

import Combine
import Foundation
import SwiftState

Expand Down Expand Up @@ -111,6 +112,11 @@ class UserSessionFlowCoordinatorStateMachine {
stateMachine.state
}

var stateSubject = PassthroughSubject<State, Never>()
var statePublisher: AnyPublisher<State, Never> {
stateSubject.eraseToAnyPublisher()
}

init() {
stateMachine = StateMachine(state: .initial)
configure()
Expand Down Expand Up @@ -178,6 +184,10 @@ class UserSessionFlowCoordinatorStateMachine {
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`")
}
}

addTransitionHandler { [weak self] context in
self?.stateSubject.send(context.toState)
}
}

/// Attempt to move the state machine to another state through an event
Expand Down
28 changes: 28 additions & 0 deletions UnitTests/Sources/Extensions/XCTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,34 @@ extension XCTestCase {
return deferred
}

/// XCTest utility that assists in subscribing to a publisher and deferring the failure for a particular value until some other actions have been performed.
/// - Parameters:
/// - publisher: The publisher to wait on.
/// - timeout: A timeout after which we give up.
/// - message: An optional custom expectation message
/// - until: callback that evaluates outputs until some condition is reached
/// - Returns: The deferred fulfilment to be executed after some actions. The publisher's result is not returned from this fulfilment.
func deferFailure<P: Publisher>(_ publisher: P,
timeout: TimeInterval,
message: String? = nil,
until condition: @escaping (P.Output) -> Bool) -> DeferredFulfillment<Void> where P.Failure == Never {
let expectation = expectation(description: message ?? "Awaiting publisher")
expectation.isInverted = true
var hasFulfilled = false
let cancellable = publisher
.sink { value in
if condition(value), !hasFulfilled {
expectation.fulfill()
hasFulfilled = true
}
}

return DeferredFulfillment<Void> {
await self.fulfillment(of: [expectation], timeout: timeout)
cancellable.cancel()
}
}

struct DeferredFulfillment<T> {
let closure: () async throws -> T
@discardableResult func fulfill() async throws -> T {
Expand Down
5 changes: 5 additions & 0 deletions UnitTests/Sources/RoomFlowCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,19 @@ class RoomFlowCoordinatorTests: XCTestCase {
func testNoOp() async throws {
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
let detailsCoordinator = navigationStackCoordinator.rootCoordinator

roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
await Task.yield()

XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssert(navigationStackCoordinator.rootCoordinator === detailsCoordinator)
}

func testPushDetails() async throws {
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)

try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
Expand Down
72 changes: 30 additions & 42 deletions UnitTests/Sources/UserSessionFlowCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,121 +56,109 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
notificationManager: NotificationManagerMock(),
isNewLogin: false)

let deferred = deferFulfillment(userSessionFlowCoordinator.statePublisher) { $0 == .roomList(selectedRoomID: nil) }
userSessionFlowCoordinator.start()
try await deferred.fulfill()
}

func testRoomPresentation() async throws {
try await process(route: .room(roomID: "1"))
try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomList)
try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)

try await process(route: .room(roomID: "1"))
try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .room(roomID: "2"))
try await process(route: .room(roomID: "2"), expectedState: .roomList(selectedRoomID: "2"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomList)
try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
}

func testRoomDetailsPresentation() async throws {
try await process(route: .roomDetails(roomID: "1"))
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomList)
try await process(route: .roomList, expectedState: .roomList(selectedRoomID: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
}

func testStackUnwinding() async throws {
try await process(route: .roomDetails(roomID: "1"))
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .room(roomID: "2"))
try await process(route: .room(roomID: "2"), expectedState: .roomList(selectedRoomID: "2"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}

func testNoOp() async throws {
try await process(route: .roomDetails(roomID: "1"))
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomDetails(roomID: "1"))
let unexpectedFulfillment = deferFailure(userSessionFlowCoordinator.statePublisher, timeout: 1) { _ in true }
userSessionFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()

XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}

func testSwitchToDifferentDetails() async throws {
try await process(route: .roomDetails(roomID: "1"))
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomDetails(roomID: "2"))
try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(selectedRoomID: "2"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}

func testPushDetails() async throws {
try await process(route: .room(roomID: "1"))
try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .roomDetails(roomID: "1"))
let unexpectedFulfillment = deferFailure(userSessionFlowCoordinator.statePublisher, timeout: 1) { _ in true }
userSessionFlowCoordinator.handleAppRoute(.roomDetails(roomID: "1"), animated: true)
try await unexpectedFulfillment.fulfill()

XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 1)
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}

func testReplaceDetailsWithTimeline() async throws {
try await process(route: .roomDetails(roomID: "1"))
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator)

try await process(route: .room(roomID: "1"))
try await process(route: .room(roomID: "1"), expectedState: .roomList(selectedRoomID: "1"))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}

// MARK: - Private

private func process(route: AppRoute) async throws {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
try await Task.sleep(for: .milliseconds(25))
}

private func process(route: AppRoute, expectedAction: UserSessionFlowCoordinatorAction) async throws {
try await process(route: route, expectedActions: [expectedAction])
}

private func process(route: AppRoute, expectedActions: [UserSessionFlowCoordinatorAction]) async throws {
guard !expectedActions.isEmpty else {
return
}

var fulfillments = [DeferredFulfillment<UserSessionFlowCoordinatorAction>]()

for expectedAction in expectedActions {
fulfillments.append(deferFulfillment(userSessionFlowCoordinator.actionsPublisher) { action in
action == expectedAction
})
}
private func process(route: AppRoute, expectedState: UserSessionFlowCoordinatorStateMachine.State) async throws {
// Sometimes the state machine's state changes before the coordinators have updated the stack.
let delayedPublisher = userSessionFlowCoordinator.statePublisher.delay(for: .milliseconds(10), scheduler: DispatchQueue.main)

let deferred = deferFulfillment(delayedPublisher) { $0 == expectedState }
userSessionFlowCoordinator.handleAppRoute(route, animated: true)

for fulfillment in fulfillments {
try await fulfillment.fulfill()
}
try await deferred.fulfill()
}
}

0 comments on commit 85826d7

Please sign in to comment.