JavaRush /Курси /Swift SELF /Async‑тести в XCTest: async-методи, таймаути

Async‑тести в XCTest: async-методи, таймаути

Swift SELF
Рівень 66 , Лекція 2
Відкрита

1. async‑тести — окрема тема

Коли ви вперше бачите async/await, хочеться радісно подумати: «Ура, більше жодних зворотних викликів — отже, тести теж стануть такими простими, як XCTAssertEqual(2 + 2, 4)». Частково це правда. Але зʼявляється інше завдання: як тесту коректно дочекатися асинхронного результату й не зависнути назавжди. XCTest розвʼязує це двома шляхами: async-методами тестів і механікою очікувань (XCTestExpectation) з таймаутами.

У цьому уроці ми розберемо, як виглядає сучасний стиль async-тестів у XCTest, як перевіряти очікувані помилки в async-коді й навіщо навіть в епоху async/await усе ще потрібен XCTestExpectation.

Базова форма async-тесту в XCTest

XCTest влаштований досить просто: він знаходить методи, назви яких починаються з test, запускає їх, ловить падіння й збирає звіт. У синхронному світі тест закінчувався, щойно завершувався метод. У світі async тест має вміти дочекатися await-точок, не перетворюючись на набір костилів із семафорами та молитвами.

Добра новина: XCTest підтримує тестові методи, позначені як async (і навіть async throws). Це означає, що всередині тесту можна робити нормальний try await, а XCTest дочекається завершення. Погана новина: деякі звичні асерти, наприклад перевірка на помилку, не мають прямого async-аналога. Тоді доведеться писати do/catch вручну. Але це якраз нормально: ми не покладаємося на магію там, де можна написати шість рядків і бути впевненими.

Мінімальний шаблон виглядає так:

import XCTest

final class ApiClientAsyncTests: XCTestCase {
    func test_skeleton() async throws {
        let x = 2 + 3
        XCTAssertEqual(x, 5) // 5
    }
}

Зверніть увагу: тест позначений async throws, і це дуже зручна комбінація. Якщо всередині ви робите try await, код виглядає максимально природно.

2. Чому мережу майже завжди тестуємо через мок

Коли йдеться про тести мережі, у новачків часто виникає спокуса перевірити все «по-справжньому»: піти на https://example.com, отримати JSON і переконатися, що декодування працює. Це звучить як інтеграційний тест, а насправді виходить лотерея: мережа може збоїти, DNS — сповільнитися, сервер — повернути іншу відповідь, а ваш тест раптом стане нестабільним. У світі тестів це приблизно як перевіряти арифметику за курсом біткоїна: технічно можна, але здоровий глузд просить зупинитися.

Тому в поточній архітектурі ApiClient залежить від HTTPClient, і це ключ до тестованості: у тестах ми підміняємо MockHTTPClient, який детерміновано повертає заздалегідь задані (Data, HTTPURLResponse) або кидає помилку. Тоді тест перевіряє саме вашу логіку: «чи правильно оброблено статус», «чи правильно декодуємо JSON», «чи правильно перетворюємо системну помилку на NetworkError.transport».

Схема тесту виходить дуже простою:

flowchart LR
    A[Тест] --> B[MockHTTPClient: заздалегідь заданий результат]
    B --> C[ApiClient.fetch]
    C --> D[перевірки через XCTAssert або do-catch]

Жодної реальної мережі, жодної випадковості — лише логіка.

3. Допоміжні функції для тестів

Тести мають бути читабельними. Якщо ваш тест на 80 % складається з «як зібрати HTTPURLResponse» і на 20 % з «що саме ми перевіряємо», мозок студента починає тихо йти у відпустку. Тому нормально мати невеликі допоміжні функції у тестовому файлі.

Наприклад, допоміжна функція для HTTPURLResponse (якщо його не вдається створити, це проблема тесту, а не системи, тому в такій допоміжній функції можна дозволити собі fatalError):

import Foundation

func makeHTTPResponse(url: URL, statusCode: Int) -> HTTPURLResponse {
    guard let response = HTTPURLResponse(
        url: url,
        statusCode: statusCode,
        httpVersion: nil,
        headerFields: nil
    ) else {
        fatalError("Не вдалося зібрати HTTPURLResponse")
    }
    return response
}

І ще одна допоміжна функція: швидко створити Data з JSON-рядка (нам важливий UTF-8, і якщо рядок раптом не кодується — знову зламався тест, а не бойовий код):

import Foundation

func jsonData(_ s: String) -> Data {
    guard let data = s.data(using: .utf8) else { fatalError("Некоректний UTF-8") }
    return data
}

Ці дрібниці помітно розвантажують тести: ви читаєте їх як сценарій, а не як збірку ритуалів.

4. Async-тест на успіх: 2xx + валідний JSON → DTO

Спершу важливо покрити найприємніший сценарій: сервер повернув успішний статус (будь-який 2xx) і коректний JSON. Ми очікуємо, що ApiClient.fetch поверне декодовану модель.

Припустімо, у нас є DTO:

import Foundation

struct PingDTO: Decodable {
    let message: String
}

Тоді тест можна написати коротко: підготувати мок, викликати fetch, порівняти результат. Зверніть увагу: це справжній async-код, тож expectations не потрібні.

import XCTest
import Foundation

final class ApiClientAsyncTests: XCTestCase {
    func test_fetch_decodesDTO_on2xx() async throws {
        let url = URL(string: "https://example.com/ping")!
        let data = jsonData(#"{"message":"pong"}"#)

        let http = MockHTTPClient(
            result: .success((data, makeHTTPResponse(url: url, statusCode: 200)))
        )
        let api = ApiClient(http: http)

        let dto: PingDTO = try await api.fetch(URLRequest(url: url))
        XCTAssertEqual(dto.message, "pong") // pong
    }
}

Тут важливий стиль: один тест — один сценарій. Ми не намагаємося в цьому ж тесті перевірити і «поганий JSON», і «500», і «правильний URL». Для цього будуть окремі тести. У тестів, як і в людей, теж є межа когнітивного навантаження.

5. Async-тест на помилку: чому XCTAssertThrowsError не рятує

У синхронних тестах є улюблена кнопка новачка: XCTAssertThrowsError(...). Проблема в тому, що цей асерт приймає autoclosure, а autoclosure за правилами Swift не може бути async. Тому «просто написати XCTAssertThrowsError(try await ... )» зазвичай не вийде.

Замість цього використовується старий добрий патерн:

  • виконуємо try await всередині do,
  • якщо помилка не сталася — робимо XCTFail,
  • у catch перевіряємо, що це саме очікувана помилка.

Це виглядає багатослівно, але на практиці дає максимальний контроль, а заодно вчить вас не плутати «впало» і «впало правильно».

Помилка статусу: не-2xx → NetworkError.httpStatus

Припустімо, ApiClient за контрактом кидає NetworkError.httpStatus(code:body:), якщо статус не входить у діапазон 200...299. Тоді тест можна написати так:

import XCTest
import Foundation

final class ApiClientAsyncTests: XCTestCase {
    func test_fetch_throwsHttpStatus_on500() async {
        let url = URL(string: "https://example.com/ping")!
        let http = MockHTTPClient(
            result: .success((Data(), makeHTTPResponse(url: url, statusCode: 500)))
        )
        let api = ApiClient(http: http)

        do {
            _ = try await api.fetch(URLRequest(url: url), as: PingDTO.self)
            XCTFail("Очікувалася помилка")
        } catch let e as NetworkError {
            if case .httpStatus(let code, _) = e {
                XCTAssertEqual(code, 500)
            } else {
                XCTFail("Неправильна помилка: \\(e)")
            }
        } catch {
            XCTFail("Неочікуваний тип помилки: \\(error)")
        }
    }
}

Так, це виглядає щільно. У реальному проєкті ви часто виносите перевірку помилки в невелику функцію, щоб тест читався легше. Але навіть у такому вигляді видно головне: ми розрізняємо «помилку нашого домену» (NetworkError) і будь-яку іншу помилку.

Помилка декодування: 2xx + невалідний JSON → NetworkError.decoding

Тепер сценарій: статус успішний, але JSON не відповідає DTO. Це особливо важливий кейс: транспорт спрацював, сервер відповів, але контракт даних порушено.

import XCTest
import Foundation

final class ApiClientAsyncTests: XCTestCase {
    func test_fetch_throwsDecoding_onBrokenJSON() async {
        let url = URL(string: "https://example.com/ping")!
        let data = jsonData(#"{"msg":"pong"}"#) // немає поля message
        let http = MockHTTPClient(
            result: .success((data, makeHTTPResponse(url: url, statusCode: 200)))
        )
        let api = ApiClient(http: http)

        do {
            _ = try await api.fetch(URLRequest(url: url), as: PingDTO.self)
            XCTFail("Очікувалася помилка декодування")
        } catch let e as NetworkError {
            if case .decoding = e {
                XCTAssertTrue(true)
            } else {
                XCTFail("Неправильна помилка: \\(e)")
            }
        } catch {
            XCTFail("Неочікувана помилка: \\(error)")
        }
    }
}

Тут ми не перевіряємо текст помилки декодера і не порівнюємо рядки (це майже завжди крихко). Ми перевіряємо категорію помилки: .decoding.

6. Перевірка вхідного URLRequest

Тестувати лише те, що повернулося, — це лише половина справи. Друга половина: переконатися, що ваш код узагалі надіслав правильний запит. Саме тому в MockHTTPClient зазвичай є receivedRequests.

Перевірка запиту особливо важлива пізніше, коли ви почнете будувати URLRequest з ендпоінтів, query-параметрів і заголовків. Але навіть на поточному рівні корисно закріпити звичку.

Мінітест: зробили fetch, потім перевірили, що мок побачив рівно один запит і що в нього правильний URL:

import XCTest
import Foundation

final class ApiClientAsyncTests: XCTestCase {
    func test_fetch_sendsExactlyOneRequest() async throws {
        let url = URL(string: "https://example.com/ping")!
        let data = jsonData(#"{"message":"pong"}"#)

        let http = MockHTTPClient(
            result: .success((data, makeHTTPResponse(url: url, statusCode: 200)))
        )
        let api = ApiClient(http: http)

        _ = try await api.fetch(URLRequest(url: url), as: PingDTO.self)
        XCTAssertEqual(http.receivedRequests.count, 1) // 1
    }
}

Порівнювати весь URLRequest «як є» буває складно (там багато полів), тому частіше порівнюють ключові частини: url, httpMethod, заголовки. Головне — не перетворювати тест на бухгалтерію на 200 рядків.

7. Таймаути й очікування в XCTest

Кожен асинхронний код несе ризик: якщо в логіці є вада, якщо completion не викликається або якщо await ніколи не завершується, тест може зависнути. XCTest не любить завислі тести так само, як ви не любите нескінченні «завантаження» в застосунку.

Таймаут — це страховка, яка гарантує, що тест завершиться передбачувано. У «чистих» async-тестах із моками таймаути часто не потрібні, бо await завершується одразу. Але щойно зʼявляються:

  • callback-API (completion handlers),
  • події, які приходять колись потім,
  • очікування сповіщень,
  • перевірка, що щось не сталося занадто довго,

тут уже доводиться користуватися XCTestExpectation.

Щоб не плутатися, зручно тримати в голові таку табличку:

Ситуація Що використовувати Чому
ApiClient.fetch поверх мока, усе детерміновано async-тест + await простіше, швидше, без очікувань
Потрібно дочекатися completion handler XCTestExpectation + таймаут XCTest має знати, коли все готово
Потрібно дочекатися кількох подій кілька expectations тест закінчується, коли всі fulfilled
Потрібно «не зависнути» таймаут в очікуванні інакше тест може «висіти» нескінченно

XCTestExpectation: класика для подій

XCTestExpectation — це обʼєкт, який позначає подію, що має статися в майбутньому. Ви створюєте expectation, запускаєте асинхронну дію, а коли подія стається — викликаєте fulfill(). Після цього тест очікує fulfillment із таймаутом.

У синхронних тестах часто писали так:

let exp = expectation(description: "Викликано callback")
someAsync { exp.fulfill() }
wait(for: [exp], timeout: 1.0)

В async-тестах XCTest дає зручнішу форму: очікування можна робити через await, щоб тест залишався в async-стилі. Приклад — для розуміння ідеї:

import XCTest

final class ExpectationOverviewTests: XCTestCase {
    func test_expectation_inAsyncTest() async {
        let exp = expectation(description: "Роботу виконано")

        Task { exp.fulfill() } // імітуємо подію
        await fulfillment(of: [exp], timeout: 1.0)
    }
}

Зверніть увагу: тут немає try await і немає throws, тому тест просто async. Насправді ви будете викликати fulfill() з completion-обробника або з коду, який реагує на подію.

Як тестувати callback-API з completion handler

Хоча нині ми активно перейшли на async/await, у реальному проєкті ви майже завжди натикатиметеся на спадщину: API на completion handlers. І навіть у URLSession у вас є варіанти, де callback-підхід ще зберігається. Тому корисно вміти тестувати обидва світи.

Ідея така: створюємо expectation, запускаємо функцію, яка викликає completion, і всередині completion виконуємо перевірки + fulfill().

Невелика іграшкова функція — лише щоб побачити форму:

import Foundation

func loadNumber(completion: @escaping (Int) -> Void) {
    DispatchQueue.global().async {
        completion(42)
    }
}

Тест під неї:

import XCTest

final class CallbackTests: XCTestCase {
    func test_loadNumber_callsCompletion() {
        let exp = expectation(description: "completion викликано")

        loadNumber { value in
            XCTAssertEqual(value, 42) // 42
            exp.fulfill()
        }

        wait(for: [exp], timeout: 1.0)
    }
}

Так, це старий стиль, але його важливо знати, бо він трапляється. А ще він пояснює, чому expectations нікуди не зникли: вони не про async/await, вони про події і таймаути.

Як обирати таймаут

Таймаут — це не привід влаштовувати змагання, у кого цифра менша. Занадто малий таймаут робить тест нестабільним: на вашій машині він проходить, на CI — падає, а потім ви пів години сперечаєтеся з реальністю, яка, як відомо, не зобовʼязана бути стабільною.

У детермінованих тестах із моками таймаут зазвичай узагалі не потрібен. У тестах на callback-API таймаут обирають так, щоб він був помітно більшим за очікувану затримку, але все ще невеликим (наприклад, 0,52 секунди для дуже простих речей). Сенс таймауту — зупинити нескінченне очікування, а не довести, що ваш код швидкий. Швидкість ви вимірюватимете іншими інструментами, а тести — про коректність.

8. Типові помилки під час написання async-тестів у XCTest

Помилка № 1: тест залежить від реального інтернету.
На перший погляд це виглядає як «ну я ж перевіряю мережу», а фактично ви перевіряєте Wi‑Fi сусіда, настрій DNS і політичну стабільність датацентру. Юніт-тест має бути детермінованим: використовуйте мок транспорту (MockHTTPClient), а реальну мережу залишайте інтеграційним тестам, якщо вони вам справді потрібні.

Помилка №2: перевіряємо лише факт помилки, але не її зміст.
Тест виду «впало — значить нормально» нічого не гарантує: могло впасти через .transport, а ви очікували .httpStatus, і завтра ви випадково зламаєте класифікацію помилок, а тест буде зеленим. Правильний стиль: catch let e as NetworkError { switch e { ... } } і перевірка конкретного кейса.

Помилка №3: спроба використати XCTAssertThrowsError з try await.
Це одна з найчастіших пасток. Не тому, що ви «поганий програміст», а тому, що мозок намагається застосувати знайомий інструмент. В async-тестах найчастіше пишуть do/try await/catch вручну й роблять XCTFail, якщо помилки не було.

Помилка №4: очікування без таймауту або expectation без fulfill().
Якщо ви використовуєте XCTestExpectation, тест має або дочекатися виконання з таймаутом, або явно впасти. Інакше ви отримаєте завислий тест, який буде «виконуватися» вічно і зрештою може бути зупинений тест-раннером найсумнішим способом.

Помилка №5: Task.sleep як «універсальний спосіб дочекатися».
Іноді хочеться написати try await Task.sleep(...) і потім перевірити, що щось сталося. Це майже завжди робить тест повільнішим і менш надійним: або ви спите замало, і тест стає нестабільним, або забагато, і все просто повільно. Якщо ви чекаєте подію — використовуйте expectation. Якщо у вас детермінований мок — не чекайте взагалі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ