Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

transitionCoordinator nil inside unit test

I've created a custom pushViewController method with a callback once transition completed. It works on the app but can't make it work on unit tests. This is the UINavigationController extension:

extension UINavigationController {
  public func pushViewController(_ viewController: UIViewController, animated: Bool, completion: @escaping (Void) -> Void) {
    pushViewController(viewController, animated: animated)
    guard animated, let coordinator = transitionCoordinator else {
      completion()
      return
    }

    coordinator.animate(alongsideTransition: nil) { _ in completion() }

  }
}

When I call it inside a unit test transitionCoordinator is always nil. I've tried setting up a window, setting navigation as root controller, and making that window key and visible. I've also tried accessing view property so VC is loaded but nothing seems to work. transitionCoordinator property is always nil.

Any ideas? Is this the expected behavior on unit tests?

like image 220
David Collado Avatar asked Nov 19 '22 04:11

David Collado


1 Answers

I think the main problem that causes the transitionCoordinator to be nil in your unit test method is that the initial viewController(rootViewController) has not yet finished it's appearing transition(this is done by the container). If the transition is not finished then when you call push() no animation will be performed (and thus no transitionCoordinator object will be created). Also for some reason unit tests will refuse to make my custom window key, so I used the hosting's app window. I tested the following and it seems to work:

func testPushTransition() throws {
    // Use the main app window that it is already key and visible
    // Otherwise it will might not make key your custom window
    let window = UIApplication.shared.keyWindow!

    let viewController = UIViewController()
    let navigationController = UINavigationController(rootViewController: viewController)
    window.rootViewController = navigationController

    let expectation = self.expectation(description: "transition animation completed")

    // Need to wait for the initial view controller to
    // finish the appearance transition
    // In order to the transition to be animated with a coordinator object
    waitToEventually(viewController.viewIfLoaded?.window != nil)

    let vc2 = UIViewController()
    navigationController.pushViewController(vc2, animated: true, completion: {
        expectation.fulfill()
    })

    waitForExpectations(timeout: 3, handler: nil)
}

func waitToEventually(_ test: @autoclosure () -> Bool, timeout: TimeInterval = 1.0, message: String = "") {
    let runLoop = RunLoop.current
    let timeoutDate = Date(timeIntervalSinceNow: timeout)
    repeat {
        if test() {
            return
        }
        runLoop.run(until: Date(timeIntervalSinceNow: 0.01))
    } while Date().compare(timeoutDate) == .orderedAscending
    XCTFail(message)
}

Side note for better testing:

I would test your code in a different way. First you should avoid having to wait for animations/transitions to finish. Second you should not test the Apple's code, you should mock all dependencies you have. In your tests you should mock the foreign dependency you have on transitionCoordinator and make it's behavior predictable. You only need to test the behavior of the extension you made, that is that your code will call the coordinator.animate(alongsideTransition: nil) and execute the completion() when it's completion handler is called back. Here how to achieve this:

So first you need to refactor your code in order to be able to inject the dependency(transitionCoordinator):

public func pushViewController(_ viewController: UIViewController,
                               animated: Bool,
                               completion: @escaping () -> Void,
                               transitionCoordinator: @autoclosure () -> UIViewControllerTransitionCoordinator?) {
    pushViewController(viewController, animated: animated)
    guard animated, let coordinator = transitionCoordinator() else {
        completion()
        return
    }

    coordinator.animate(alongsideTransition: nil) { _ in
        completion()
    }
}

//The old interface can use as default injected property the self.transitionCoordinator
public func pushViewController(_ viewController: UIViewController,
                               animated: Bool,
                               completion: @escaping () -> Void) {
    self.pushViewController(viewController, animated: animated, completion: completion,
                            transitionCoordinator: self.transitionCoordinator)
}

Then create a mock implementation of the the UIViewControllerTransitionCoordinator what you will use in the unit tests:

class UIViewControllerTransitionCoordinatorMocked: NSObject, UIViewControllerTransitionCoordinator {

var animateWasCalled = false

func animate(alongsideTransition animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)? = nil) -> Bool {

    animateWasCalled = true

    if let completion = completion {
        completion(self)
    }
    return true
}

//... see full example
// https://gist.github.com/csknns/c420818029718f9184887e3be7e6a932

}

Then I would write the unit test like this:

func testCustomPushMethod() throws {
    let viewController = UIViewController()
    let navigationController = UINavigationController(rootViewController: viewController)
    let coordinator = UIViewControllerTransitionCoordinatorMocked()

    var completionWasCalled = false
    navigationController.pushViewController(ViewController(),
                                            animated: true,
                                            completion: { completionWasCalled = true}
                                            , transitionCoordinator: coordinator)

    XCTAssertTrue(coordinator.animateWasCalled)
    XCTAssertTrue(completionWasCalled)
}
like image 94
Christos Koninis Avatar answered Dec 24 '22 15:12

Christos Koninis