Модульное тестирование асинхронного/ожидающего кода Swift

Модульные тесты позволяют вам проверять код, написанный с использованием новейшей инфраструктуры параллелизма и async/await. Хотя написание тестов не сильно отличается от синхронных тестов, есть несколько важных концепций, о которых следует помнить при проверке асинхронного кода.

Если вы новичок в async/await, я рекомендую вам сначала прочитать Async await в Swift с примерами кода. Точно так же для модульных тестов вы можете начать работу, прочитав Начало работы с модульными тестами в Swift. В этой статье мы собираемся погрузиться в написание тестов для кода с использованием новейшей среды параллелизма.

Тестирование асинхронного кода

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

struct ImageFetcher {
    enum Error: Swift.Error {
        case imageCastingFailed
    }

    func fetchImage(for url: URL) async throws -> UIImage {
        let (data, _) = try await URLSession.shared.data(from: url)
        guard let image = UIImage(data: data) else {
            throw Error.imageCastingFailed
        }
        return image
    }
}

Код относительно прост и помогает нам объяснить концепцию тестирования кода, использующего async/await. Обратите внимание, что выполнять сетевые запросы в модульных тестах не рекомендуется. Если вам интересно решить эту проблему, вы можете прочитать мою статью Как имитировать запросы Alamofire и URLSession в Swift.

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

final class ImageFetcherTests: XCTestCase {
    /// We marked the method to be async and throwing.
    func testImageFetching() async throws {
        let imageFetcher = ImageFetcher()

        /// The image URL in this example returns a random image.
        /// I recommend mocking outgoing network requests as a best practice:
        /// https://avanderlee.com/swift/mocking-alamofire-urlsession-requests/
        let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!

        let image = try await imageFetcher.fetchImage(for: imageURL)
        XCTAssertNotNil(image)
    }
}

Отметив наше определение модульного теста с помощью async и throws, вы можете:

  • Вызовите асинхронный код непосредственно в вашем модульном тесте, используя await
  • Используйте методы выбрасывания с помощью try и позвольте тесту автоматически завершиться неудачно, когда возникнет ошибка

Мы упрощаем модульный тест, помечая наше определение теста как асинхронное и вызывающее. Использование структуры Task или try-catch для модульного тестирования асинхронной/ожидающей логики не требуется.

Модульное тестирование логики пользовательского интерфейса с использованием @MainActor

Во время модульного тестирования async/await вы можете столкнуться со следующей ошибкой:

Выражение «асинхронно», но не помечено «ожиданием».

Xcode может отображать эту ошибку при модульном тестировании кода, который обращается к логике пользовательского интерфейса, такой как контроллеры представления:

Такие типы, как UIViewController, содержат атрибут @MainActor, требующий доступа к ним только в основном потоке. Вы можете прочитать больше об этом атрибуте в разделе Использование MainActor в Swift, объясненном для отправки в основной поток.

В этом случае мы можем пометить конструктор с помощью await. Однако, как только мы захотим проверить конфигурацию, мы столкнемся со следующей ошибкой:

Свойство «image», изолированное от основного актера, не может быть указано в неизолированном автозамыкании.

Правильное решение — пометить метод модульного тестирования атрибутом @MainActor:

@MainActor
func testImageConfiguration() async {
    let viewController = ImageViewController()
    let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
    await viewController.configureImage(using: imageURL)
    XCTAssertNotNil(viewController.imageView.image)
}

Это позволяет нам удалить ключевое слово await перед конструктором ImageViewController, так как модульный тест теперь будет выполняться в контексте, изолированном от основного актера.

Предотвращение взаимоблокировок XCTestExpectation

Фреймворк параллелизма все еще относительно новый, и вы, скорее всего, работаете с более старой кодовой базой, в которой используются тестовые ожидания. Модульное тестирование async/await логики с использованием ожиданий может привести к так называемым взаимоблокировкам: ситуации, в которой два или несколько потоков бесконечно ожидают друг друга.

На самом деле, начиная с Xcode 14.3, вы можете столкнуться со следующим предупреждением:

Метод экземпляра «wait» недоступен из асинхронных контекстов; Вместо этого используйте ожидание выполнения(of:timeout:enforceOrder:); это ошибка в Swift 6

Предупреждение понятно и помогает решить проблему альтернативным методом, но предупреждение появляется не всегда. Скорее всего, вы еще не пометили свои модульные тесты асинхронностью, как показано ниже:

func testImageConfigurationCompletionCallback() {
    let viewController = ImageViewController()
    let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
    let imageConfigurationExpectation = expectation(description: "Image should be configured")
    viewController.configureImage(using: imageURL, completion: {
        imageConfigurationExpectation.fulfill()
    })
    wait(for: [imageConfigurationExpectation])
}

В этом примере мы проверяем метод ImageViewController.configureImage, который выглядит следующим образом:

func configureImage(using url: URL, completion: @escaping () -> Void) {
    Task {
        let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
        let image = try? await ImageFetcher().fetchImage(for: imageURL)

        await MainActor.run {
            imageView.image = image
            completion()
        }
    }
}

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

Вы можете решить эту проблему, переписав свои модульные тесты, которые проверяют параллельный код, используя метод выполнения ожидания:

@MainActor
func testImageConfigurationCompletionCallback() async {
    let viewController = ImageViewController()
    let imageURL = URL(string: "https://source.unsplash.com/random/300x200")!
    let imageConfigurationExpectation = expectation(description: "Image should be configured")
    viewController.configureImage(using: imageURL, completion: {
        imageConfigurationExpectation.fulfill()
    })
    await fulfillment(of: [imageConfigurationExpectation])
}

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

Заключение

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