Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

ios

swift

xctest

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?

like image 320
Bartłomiej Semańczyk Avatar asked Oct 26 '18 05:10

Bartłomiej Semańczyk


3 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.

like image 60
Alex Machado Avatar answered Oct 19 '22 10:10

Alex Machado


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.

like image 9
Ilya Kharabet Avatar answered Oct 19 '22 09:10

Ilya Kharabet


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

like image 3
Pastre Avatar answered Oct 19 '22 09:10

Pastre