How to test method that is called with DispatchQueue.main.async?

Viewed 7386

In code I do it like this:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    updateBadgeValuesForTabBarItems()
}

private func updateBadgeValuesForTabBarItems() {
    DispatchQueue.main.async {
        self.setBadge(value: self.viewModel.numberOfUnreadMessages, for: .threads)
        self.setBadge(value: self.viewModel.numberOfActiveTasks, for: .tasks)
        self.setBadge(value: self.viewModel.numberOfUnreadNotifications, for: .notifications)
    }
}

and in tests:

func testViewDidAppear() {
    let view = TabBarView()
    let model = MockTabBarViewModel()
    let center = NotificationCenter()
    let controller = TabBarController(view: view, viewModel: model, notificationCenter: center)
    controller.viewDidLoad()
    XCTAssertFalse(model.numberOfActiveTasksWasCalled)
    XCTAssertFalse(model.numberOfUnreadMessagesWasCalled)
    XCTAssertFalse(model.numberOfUnreadNotificationsWasCalled)
    XCTAssertFalse(model.indexForTypeWasCalled)
    controller.viewDidAppear(false)
    XCTAssertTrue(model.numberOfActiveTasksWasCalled) //failed
    XCTAssertTrue(model.numberOfUnreadMessagesWasCalled) //failed
    XCTAssertTrue(model.numberOfUnreadNotificationsWasCalled) //failed
    XCTAssertTrue(model.indexForTypeWasCalled) //failed
}

But all my four latest assertions failed. Why? How can I test it with success?

7 Answers

I think the best approach to test this is to mock the DispatchQueue. You can create a protocol that defines the functionality that you want to use:

protocol DispatchQueueType {
    func async(execute work: @escaping @convention(block) () -> Void)
}

Now extend DispatchQueue to conform to your protocol, like:

extension DispatchQueue: DispatchQueueType {
    func async(execute work: @escaping @convention(block) () -> Void) {
        async(group: nil, qos: .unspecified, flags: [], execute: work)
    }
}

Note I had to omit from the protocol the parameters you didn't use in your code, like group, qos, and flags, since protocol don't allow default values. And that's why the extension had to explicitly implement the protocol function.

Now, in your tests, create a mocked DispatchQueue that conforms to that protocol and calls the closure synchronously, like:

final class DispatchQueueMock: DispatchQueueType {
    func async(execute work: @escaping @convention(block) () -> Void) {
        work()
    }
}

Now, all you need to do is inject the queue accordingly, perhaps in the view controller's init, like:

final class ViewController: UIViewController {
    let mainDispatchQueue: DispatchQueueType

    init(mainDispatchQueue: DispatchQueueType = DispatchQueue.main) {
        self.mainDispatchQueue = mainDispatchQueue
        super.init(nibName: nil, bundle: nil)
    }

    func foo() {
        mainDispatchQueue.async {
            *perform asynchronous work*
        }
    }
}

Finally, in your tests, you need to create your view controller using the mocked dispatch queue, like:

func testFooSucceeds() {
    let controller = ViewController(mainDispatchQueue: DispatchQueueMock())
    controller.foo()
    *assert work was performed successfully*
}

Since you used the mocked queue in your test, the code will be executed synchronously, and you don't need to frustratingly wait for expectations.

You don't need to call the code in the updateBadgeValuesForTabBarItems method on the main queue.

But if you really need it, you can do something like this:

func testViewDidAppear() {
    let view = TabBarView()
    let model = MockTabBarViewModel()
    let center = NotificationCenter()
    let controller = TabBarController(view: view, viewModel: model, notificationCenter: center)
    controller.viewDidLoad()
    XCTAssertFalse(model.numberOfActiveTasksWasCalled)
    XCTAssertFalse(model.numberOfUnreadMessagesWasCalled)
    XCTAssertFalse(model.numberOfUnreadNotificationsWasCalled)
    XCTAssertFalse(model.indexForTypeWasCalled)
    controller.viewDidAppear(false)
    let expectation = self.expectation(description: "Test")
    DispatchQueue.main.async {
        expectation.fullfill()
    }
    self.waitForExpectations(timeout: 1, handler: nil)
    XCTAssertTrue(model.numberOfActiveTasksWasCalled)
    XCTAssertTrue(model.numberOfUnreadMessagesWasCalled)
    XCTAssertTrue(model.numberOfUnreadNotificationsWasCalled)
    XCTAssertTrue(model.indexForTypeWasCalled)
}

But this is not good practice.

You should

  1. Inject the dependency (DispatchQueue) into your view controller, so that you can change it in the tests
  2. Invert the dependency using a protocol, to better conform to SOLID principles (Interface seggregation and Dependency Inversion)
  3. Mock DispatchQueue in your tests, so that you can control your scenario

Lets apply those three items:

To invert the dependency, we will need an abstract type, that is, in Swift, a protocol. We then extend DispatchQueue to conform to that protocol

protocol Dispatching {
    func async(execute workItem: DispatchWorkItem)
}

extension DispatchQueue: Dispatching {}

Next, we need to inject the dependency into our view controller. That means, pass anything that is dispatching to our view controller

final class MyViewController {
    // MARK: - Dependencies
    
    private let dispatchQueue: Dispatching // Declading that our class needs a dispatch queue

    // MARK: - Initialization

    init(dispatchQueue: Dispatching = DispatchQueue.main) { // Injecting the dependencies via constructor
        self.dispatchQueue = dispatchQueue
        super.init(nibName: nil, bundle: nil) // We must call super 
    }

    @available(*, unavailable)
    init(coder aCoder: NSCoder?) {
        fatalError("We should only use our other init!")
    }

    // MARK: - View lifecycle

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        updateBadgeValuesForTabBarItems()
    }

    // MARK: - Private methods
    private func updateBadgeValuesForTabBarItems() {
        dispatchQueue.async { // Using our dependency instead of DispatchQueue directly
            self.setBadge(value: self.viewModel.numberOfUnreadMessages, for: .threads)
            self.setBadge(value: self.viewModel.numberOfActiveTasks, for: .tasks)
            self.setBadge(value: self.viewModel.numberOfUnreadNotifications, for: .notifications)
        }
    }
}

Lastly, we need to create a mock for our tests. In this case, by following the testing doubles, we should create a Fake, that is, a DispatchQueue mock that doesn't really work in production, but works on our tests

final class DispatchFake: Dispatching {
    func async(execute workItem: DispatchWorkItem) {
        workItem.perform()
    }
}

When we're testing, all we need to do is create our System Under Test(the controller, in this case), passing a fake dispatching instance

I had use DispatchQueue.main.asyncAfter() in my test along with expectation otherwise it was failing before text could set inside DispatchQueue.main.async {}

Methods to Test:

func setNumpadTexts(_ numpad: NumericalKeyboardVC) {
   numpad.setTexts(belowNumberLabelText: Currency.symbol, enterKeyText: NSLocalizedString("Add", comment:""))
}    

func setTexts(belowNumberLabelText: String? = "", enterKeyText: String) {
   DispatchQueue.main.async {
       self.belowNumberDisplayLbl.text = belowNumberLabelText
       self.enterBtn.setTitle(enterKeyText, for: .normal)
   }
}

Test:

func testSetNumpadTexts() {
    sut.setNumpadTexts(numpad)
    
    let expectation = expectation(description: "TextMatching")
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: {
        //then
        XCTAssertEqual(self.numpad.enterBtn.title(for: .normal), NSLocalizedString("Add", comment:""))
        XCTAssertEqual(self.numpad.belowNumberDisplayLbl.text, Currency.symbol)
        
        expectation.fulfill()
    })
    wait(for: [expectation], timeout: 2.0)
}

You can easy achieve this by checking if current thread is main and execute code synchronously in this case.

For example in presenter I update view in this way:

  private func updateView(with viewModel: MyViewModel) {
    if Thread.isMainThread {
      view?.update(with: viewModel)
    } else {
      DispatchQueue.main.async {
        self.view?.update(with: viewModel)
      }
    }
  }

And then I can write synchronous unit tests for my presenter:

  func testOnViewDidLoadFetchFailed() throws {
    presenter.onViewDidLoad() 
    // presenter is calling interactor.fetchData when onViewDidLoad is called

    XCTAssertEqual(interactor.fetchDataCallsCount, 1)
    
    // test execute fetchData completion closure manually in the main thread
    interactor.fetchDataCalls[0].completion(.failure(TestError())) 

    // presenter will call updateView(viewModel:) internally in synchronous way
    // because we have check if Thread.isMainThread in updateView(viewModel:)
    XCTAssertEqual(view.updateCallsCount, 1)
    guard case .error = view.updateCalls[0] else {
      XCTFail("error expected, got \(view.updateCalls[0])")
      return
    }
  }

To test asynchronous code you should modify your updateBadgeValuesForTabBarItems function and call it directly from your tests with a completion closure:

func updateBadgeValuesForTabBarItems(completion: (() -> Void)? = nil) {
    DispatchQueue.main.async {
        self.setBadge(value: self.viewModel.numberOfUnreadMessages, for: .threads)
        self.setBadge(value: self.viewModel.numberOfActiveTasks, for: .tasks)
        self.setBadge(value: self.viewModel.numberOfUnreadNotifications, for: .notifications)
        completion?()
    }
}

Now you are be able to call this function as before in your regular code e.g.: updateBadgeValuesForTabBarItems(). But for tests you can add a completion closure and use XCTestExpectation to wait:

func testBadge() {
    
    ...

    let expectation = expectation(description: "Badge") 

    updateBadgeValuesForTabBarItems {
        XCTAssertTrue(model.numberOfActiveTasksWasCalled)
        XCTAssertTrue(model.numberOfUnreadMessagesWasCalled)
        XCTAssertTrue(model.numberOfUnreadNotificationsWasCalled)
        XCTAssertTrue(model.indexForTypeWasCalled)
        
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 1)
}

Here is a small proof of concept of how you could achieve it:

func testExample() {
        let expectation = self.expectation(description: "numberOfActiveTasks")
        var mockModel = MockModel()
        mockModel.numberOfActiveTasksClosure = {() in
            expectation.fulfill()
        }

        DispatchQueue.main.async {
            _ = mockModel.numberOfActiveTasks
        }


        self.waitForExpectations(timeout: 2, handler: nil)
    }

and here is the MockModel:

struct MockModel : Model {
    var numberOfActiveTasks: Int {
        get {
            if let cl = numberOfActiveTasksClosure {
                cl()
            }
            //we dont care about the actual value for this test
            return 0
        }
    }
    var numberOfActiveTasksClosure: (() -> ())?
}
Related