Если вы когда-нибудь думали о проведении юнит-тестирования в Xcode, вы наверняка обращали внимание на XCTest. Это довольно простой фреймворк на Objective-C и Swift. Однако тестирование асинхронного кода всегда было немного сложным из-за таких конструкций, как делегаты и коллбэки (функции обратного вызова).

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

Изучение классического способа написания асинхронных тестов


По умолчанию XCTest кейсы выполняются синхронно: это означает, что любой код, основанный на коллбэках, где коллбэк не вызывается синхронно и сразу, требует особого внимания при тестировании. Раньше основным инструментом для этого был XCTestExpectation — он позволял разработчикам не отмечать тест как завершённый (или провальный), пока ожидание не будет отмечено как завершённое. Это не обязательно означало, что тест завершился успешно, просто асинхронный код завершился.

В качестве напоминания (или краткого введения, если для вас это всё в новинку), вот как выглядит тест, основанный на ожиданиях:

final class MovieLoaderTests: XCTestCase {

    var loader: MovieLoader!

    override func setUp() {
        loader = MovieLoader()
    }

    func testMoviesCanBeLoaded() {
        // 1
        let moviesExpectation = expectation(description: "Expected movies to be loaded")
        // 2
        loader.loadMovies { movies in
            moviesExpectation.fulfill()

            XCTAssert(movies.count == 10, "Expected 10 movies to be loaded")
        }
        // 3
        waitForExpectations(timeout: 1)
    }

}


Этот код изначально устанавливает ожидание теста. Мы даём красивое описание, чтобы убедиться, что мы сможем легко идентифицировать это ожидание, если что-то пойдёт не так.

После этого мы вызываем асинхронный код, основанный на коллбэке. Когда метод loadMovies возвращается с набором объектов Movie (определение которых я не включил сюда, поскольку оно не имеет отношения к коду тестирования), мы выполняем ожидание, что позволяет нашему тесту продолжить работу, и делаем несколько утверждений.

Наконец, мы вызываем waitForExpectations с таймаутом, равным единице. Это означает, что мы ожидаем, что наше ожидание будет выполнено в течение одной секунды. Если этого не произойдёт, наш тест автоматически завершится неудачей.

Приведённый выше код выглядит довольно простым и понятным, но представьте на минуту, что коллбэк будет немного сложнее:

loader.loadMovies { result in
    guard let movies = try? result.get() else {
        return
    }

    moviesExpectation.fulfill()

    XCTAssert(movies.count == 10, "Expected 10 movies to be loaded")
}


В этом коде мы получаем Result<[Movie], Error> в замыкании, а не просто массив Movie. Это означает, что для проверки количества загруженных фильмов нам нужно взять успешный кейс из нашего результата (что мы можем удобно сделать с помощью get()) и затем убедиться, что у нас действительно есть список фильмов, прежде чем мы продолжим.

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

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

loader.loadMovies { result in
    moviesExpectation.fulfill()

    guard let movies = try? result.get() else {
        XCTFail("Expected movies to be loaded")
        return
    }

    XCTAssert(movies.count == 10, "Expected 10 movies to be loaded")
}


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

Также важно отметить, что отладка вышеупомянутого теста будет очень неприятной. Если мы неожиданно получим ошибку от loadMovies, мы не сразу поймём, с какой ошибкой столкнулись.

К счастью, ваши тесты станут намного, намного приятнее, как только вы начнёте переходить на async / await. Так что давайте посмотрим, как писать более современные асинхронные тесты.

Исследование тестов с помощью async / await


Async await позволяет получать результаты от асинхронных методов, просто используя ключевое слово await. Мы можем пометить любые методы, которые должны выполняться асинхронно, ключевым словом async, и система автоматически выполнит всю работу, необходимую для асинхронного выполнения этого метода.

Если вы ещё не видели, вот очень краткий пример того, как это выглядит:
// defined as
func loadMovies() async throws -> [Movie] {
    let data = try await loader.fetchMovieData()
    let movies = try JSONDecoder().decode([Movie].self, from: data)

    return movies
}

// called as
let movies = try await loadMovies()


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

Как же мы можем протестировать асинхронную функцию loadMovies?

Хорошие новости: начиная с Xcode 13 мы можем помечать тестовые методы как асинхронные. Это позволяет тестовым методам приостанавливать выполнение и ждать результатов асинхронной работы, которая инициируется из теста.

Другими словами, нам больше не нужно жонглировать ожиданиями тестов — мы можем напрямую ожидать результатов асинхронной работы внутри юнит-теста.

func testMoviesCanBeLoadedAsync() async {
    do {
        let movies = try await loader.loadMovies()
        XCTAssert(movies.count == 10, "Expected 10 movies to be loaded")
    } catch {
        XCTFail("Expected movies to be loaded")
    }
}


Это уже гораздо проще, чем предыдущие тесты. Однако нам всё ещё не хватает возможности легко понять, что пошло не так, если мы не смогли загрузить фильмы. Причина — в том, что мы ловим, но не обрабатываем ошибку. Можно обновить строку XCTFail, чтобы она включала описание ошибки, но на самом деле можно сделать лучше.

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

func testMoviesCanBeLoadedAsync() async throws {
    let movies = try await loader.loadMovies()
    XCTAssert(movies.count == 10, "Expected 10 movies to be loaded")
}


Эти две строчки кода гораздо легче читать, чем версию того же теста, основанную на коллбэках. И что ещё более важно, в тесте гораздо сложнее допустить ошибки, даже если он будет расти в количестве строк и сложности, поскольку нам больше не нужно думать о том, как оправдать наши ожидания.

Ещё один большой плюс заключается в том, что теперь можно провалить тест, если на каком-то шаге неожиданно возникнет ошибка. Xcode включит краткую информацию об ошибке в сообщение о провале теста.

Например, мы можем увидеть подобное сообщение об ошибке:
<unknown>:0: error: -[SampleApp.MovieLoaderTests testMoviesCanBeLoadedAsync] : failed: caught error: "The operation couldn’t be completed. (SampleApp.MovieError error 0.)"


Этого не всегда может быть достаточно для надёжной отправной точки для отладки. Но это гораздо лучше, чем видеть только общий сбой теста. По крайней мере, это сообщение подсказывает нам, что нужно взглянуть на определение MovieError, чтобы выяснить, какую ошибку мы определили как первый case (именно этот 0 в выводе указывает на то, что это выброшенная ошибка).

В зависимости от того, сколько информации нужно получить в результатах неудачного теста, можно написать довольно солидный асинхронный юнит-тест всего за две строки кода — что очень здорово, как по мне.

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

В заключение


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

После этого мы посмотрели, как можно пометить тестовые методы как async и throws — что позволило нам взять тест длиной почти в 10 строк и сократить его до 2-х строк. При этом улучшили отчётность при завершении теста ошибкой. И всё это — благодаря переходу на async / await.

Swift Concurrency — это очень интересная технология, которая, как вы теперь знаете, влияет не только на продакшен код. Она также оказывает благотворное влияние на код для тестирования.

Комментарии (0)