Как проверить метод, вызываемый с помощью 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
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
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

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