如何测试使用DispatchQueue.main.async调用的方法?

问题描述 投票:0回答:8

在代码中我这样做:

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)
    }
}

在测试中:

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
}

但是我最新的四个断言都失败了。为什么?我怎样才能测试成功?

ios swift xctest
8个回答
30
投票

我认为测试这一点的最佳方法是模拟

DispatchQueue
。您可以创建一个协议来定义您要使用的功能:

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

现在扩展

DispatchQueue
以符合您的协议,例如:

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

注意,我必须从协议中省略您在代码中未使用的参数,例如

group
qos
flags
,因为协议不允许使用默认值。这就是为什么扩展必须显式实现协议功能。

现在,在您的测试中,创建一个符合该协议的模拟

DispatchQueue
并同步调用闭包,例如:

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

现在,您需要做的就是相应地注入队列,也许在视图控制器的

init
中,例如:

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*
        }
    }
}

最后,在测试中,您需要使用模拟的调度队列创建视图控制器,例如:

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

由于您在测试中使用了模拟队列,因此代码将同步执行,您不需要沮丧地等待期望。


11
投票

不需要在主队列上调用

updateBadgeValuesForTabBarItems
方法中的代码。

但是如果你真的需要它,你可以这样做:

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)
}

但这不是一个好的做法。


5
投票

我在测试中使用了

DispatchQueue.main.asyncAfter()
以及预期,否则在文本设置在内部之前它会失败
DispatchQueue.main.async {}

测试方法:

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)
   }
}

测试:

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)
}

4
投票

你应该

  1. 将依赖项(DispatchQueue)注入到视图控制器中,以便您可以在测试中更改它
  2. 使用协议反转依赖关系,以更好地符合SOLID原则(接口隔离和依赖反转)
  3. Mock DispatchQueue 在您的测试中,以便您可以控制您的场景

让我们应用这三项:

为了反转依赖关系,我们需要一个abstract类型,即在 Swift 中,一个协议。然后我们扩展 DispatchQueue 以符合该协议

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

extension DispatchQueue: Dispatching {}

接下来,我们需要将依赖项注入到我们的视图控制器中。这意味着,将正在分派的任何内容传递给我们的视图控制器

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)
        }
    }
}

最后,我们需要为我们的测试创建一个模拟。在这种情况下,通过遵循 testing doubles,我们应该创建一个 Fake,即一个 DispatchQueue 模拟,它在生产中并不真正工作,但在我们的测试中工作

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

当我们测试时,我们需要做的就是创建我们的被测系统(在本例中为控制器),并传递一个假的调度实例


1
投票

您可以通过检查当前线程是否为主线程并在这种情况下同步执行代码来轻松实现这一点。

例如在演示者中我以这种方式更新视图:

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

然后我可以为我的演示者编写同步单元测试:

  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
    }
  }

0
投票

这里有一个关于如何实现它的小概念证明:

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)
    }

这是

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: (() -> ())?
}

0
投票

要测试异步代码,您应该修改

updateBadgeValuesForTabBarItems
函数并使用完成闭包直接从测试中调用它:

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?()
    }
}

现在您可以像以前一样在常规代码中调用此函数,例如:

updateBadgeValuesForTabBarItems()
。但对于测试,您可以添加完成闭包并使用
XCTestExpectation
等待:

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)
}

0
投票

2023 年 9 月更新:在 Xcode 15 上使用

threadDictionary
不再起作用。但这确实有效
return NSClassFromString(“XCTestCase”) != nil

==============

几年后,但也许有用。

在我的特定情况下,我使用

DispatchQueue
上的扩展来检测我们是否正在运行单元测试。因此,如果我们处于
XCTest
环境中,该方法会自动执行而无需分派。

public extension DispatchQueue {

    private static let isRunningUnitTests: Bool = {
        Thread.current.threadDictionary.allKeys.contains {
            ($0 as? String)?.range(of: "XCTest", options: .caseInsensitive) != nil
        }
    }()

    func asyncTestable(
        closure: @escaping @convention(block) () -> Void
    ) {
        let workItem = DispatchWorkItem(block: closure)
        asyncTestable(execute: workItem)
    }

    func asyncTestable(
        execute workItem: DispatchWorkItem
    ) {
        if !Self.isRunningUnitTests {
            async(execute: workItem)
        } else {
            workItem.perform()
        }
    }
}

这样,在我们的生产代码中,我们不使用

DispatchQueue.main.async { ... }
,而是使用
DispatchQueue.main.asyncTestable { ... }
。在您的特定场景中:

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

您的测试应该无需修改即可正常工作。

© www.soinside.com 2019 - 2024. All rights reserved.