Как проверить метод, вызываемый с помощью dispatchqueue.main.async?

В коде я делаю это так:

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
}

Но все четыре моих последних утверждения не оправдались. Почему? Как я могу его успешно протестировать?

проверьте medium.com/@johnsundell/…

ChintaN -Maddy- Ramani 26.10.2018 07:48

Дело не в этом. Любой пример с МОИМ кодом ?;)

Bartłomiej Semańczyk 26.10.2018 08:00

Избавьтесь от DispatchQueue.main.async в вашем методе.

Rob Zombie 20.09.2021 14:13
9
3
3 127
6

Ответы 6

Вам не нужно вызывать код метода 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)
}

Но это не лучшая практика.

Умный подход, поскольку мы знаем, что асинхронная отправка из тестов обязательно будет запускаться после отправки из контроллера. Как вы сказали, это не то, что нужно использовать в производственном коде, но для тестов это должно быть нормально.

Cristik 24.09.2021 06:39

Вот небольшое доказательство того, как вы могли этого добиться:

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

Я думаю, что лучший способ проверить это - издеваться над 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*
}

Поскольку вы использовали имитацию очереди в своем тесте, код будет выполняться синхронно, и вам не нужно мучительно ждать ожиданий.

Вы можете легко добиться этого, проверив, является ли текущий поток основным, и в этом случае выполнить код синхронно.

Например, в презентаторе я обновляю представление следующим образом:

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

Cristik 23.09.2021 21:17

не имеет значения, выполняется ли он в основном потоке. Потому что в случае DispatchQueue.main.async он будет выполняться не синхронно и с некоторой задержкой. Вот почему вы не можете писать синхронные тесты

Vitalii Gozhenko 24.09.2021 09:41

Верно, но предлагаемое здесь изменение меняет поведение производственного кода, и это может быть нежелательно.

Cristik 24.09.2021 12:17

Чтобы протестировать асинхронный код, вы должны изменить свою функцию 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, который будет использоваться только тестами? Не похоже на хорошую идею ...

Cristik 23.09.2021 21:15

Кроме того, для этого требуется раскрыть текущий частный метод updateBadgeValuesForTabBarItems, что может не иметь смысла с архитектурной точки зрения.

Cristik 24.09.2021 06:42

Вам следует

  1. Вводить зависимости (DispatchQueue) в вашем контроллере представления, чтобы вы могли изменить ее в тестах.
  2. Инвертировать зависимость, использующая протокол, чтобы лучше соответствовать принципам SOLID (разделение интерфейсов и инверсия зависимостей)
  3. Насмехаться DispatchQueue в ваших тестах, чтобы вы могли контролировать свой сценарий

Применим эти три пункта:

Чтобы инвертировать зависимость, нам понадобится тип Аннотация, то есть в 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)
        }
    }
}

Наконец, нам нужно создать макет для наших тестов. В этом случае, следуя двойные испытания, мы должны создать Fake, то есть макет DispatchQueue, который на самом деле не работает в продакшене, но работает в наших тестах.

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

Когда мы тестируем, все, что нам нужно сделать, это создать нашу тестируемую систему (в данном случае контроллер), передав поддельный экземпляр диспетчеризации.

Похоже, это то же самое решение, что и самый популярный ответ.

Cristik 23.09.2021 21:13

Я считаю, что это лучшее объяснение, чем «создать протокол, расширить очередь отправки и смоделировать его в ваших тестах». Это дает намного больше информации о том, почему мы делаем эти вещи, и на основе лучших принципов программирования дает понять, почему это отличное решение.

Pastre 23.09.2021 22:04

Другие вопросы по теме