【问题标题】:How to test method that is called with DispatchQueue.main.async?如何测试使用 DispatchQueue.main.async 调用的方法?
【发布时间】:2019-03-30 19:51:30
【问题描述】:

在代码中我这样做:

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
}

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

【问题讨论】:

  • 这不是重点。有我的代码的任何例子吗?;)
  • 在你的方法中去掉DispatchQueue.main.async

标签: ios swift xctest


【解决方案1】:

您不需要在主队列上调用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)
}

但这不是好的做法。

【讨论】:

  • 聪明的方法,因为我们知道来自测试的异步调度肯定会在来自控制器的调度之后运行。正如你所说,不是在生产代码中使用的东西,但对于测试它应该没问题。
【解决方案2】:

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

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

【讨论】:

    【解决方案3】:

    我认为最好的测试方法是模拟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)
        }
    }
    

    请注意,我必须从协议中省略您在代码中未使用的参数,例如 groupqosflags,因为协议不允许使用默认值。这就是为什么扩展必须显式实现协议功能的原因。

    现在,在您的测试中,创建一个符合该协议并同步调用闭包的模拟 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*
    }
    

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

    【讨论】:

      【解决方案4】:

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

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

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

      【讨论】:

      • 但是问题中的代码已经在主线程上,因为它是从viewDidAppear...调用的。
      • 在主线程中执行没关系。因为在DispatchQueue.main.async 的情况下,它将在一些延迟后不同步执行。这就是你不能编写同步测试的原因
      • 对,但是这里提出的更改会改变生产代码的行为,这可能是不希望的。
      【解决方案5】:

      要测试异步代码,您应该修改 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)
      }
      

      【讨论】:

      • 所以基本上在 AppStore 上发布了一些仅用于测试的代码?似乎不是一个好主意...
      • 另外,这需要公开当前私有的updateBadgeValuesForTabBarItems 方法,从架构的角度来看这可能没有意义。
      【解决方案6】:

      你应该

      1. 注入依赖项 (DispatchQueue) 到您的视图控制器中,以便您可以在测试中更改它
      2. 使用协议反转依赖关系,以更好地符合 SOLID 原则(接口隔离和依赖反转)
      3. 在您的测试中模拟 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()
          }
      }
      

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

      【讨论】:

      • 这似乎与最受好评的答案给出的解决方案相同。
      • 我相信这是一个比“创建协议、扩展调度队列并在测试中模拟它”更好的解释。这提供了更多关于我们为什么要做这些事情的背景,并根据最佳编程原则清楚地说明了为什么这是一个很好的解决方案
      【解决方案7】:

      我在测试中使用了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)
      }
      

      【讨论】:

      • 感谢@Ilya Kharabet 对此方法的上述回答。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2010-12-18
      • 2015-03-20
      • 2010-09-23
      • 1970-01-01
      相关资源
      最近更新 更多