JavaRush /Курсы /Swift SELF /MockHTTPClient: сценарии успеха, ошибок и битого JSON

MockHTTPClient: сценарии успеха, ошибок и битого JSON

Swift SELF
66 уровень , 0 лекция
Открыта

1. Контракт HTTPClient и базовый MockHTTPClient

Когда вы только начинаете писать сетевой код, кажется логичным: «Ну сейчас я просто дерну API — и всё». А потом наступает реальность: интернет пропадает именно тогда, когда вы нажали Run; сервер отвечает 500 ровно в момент демонстрации; а тесты внезапно становятся похожи на гадание на кофейной гуще.

Главная идея MockHTTPClient в том, что мы не тестируем интернет. Мы тестируем нашу логику: что мы отправили правильный URLRequest, что правильно обработали 200/404/500, что не сломались на неожиданном теле ответа, что корректно отдали ошибку «битый JSON». И всё это должно быть детерминированно, то есть повторяемо: один и тот же тест 100 раз подряд даёт один и тот же результат.

Кстати, в мире async/await это особенно важно: асинхронность не означает хаос, но означает, что «точки паузы» явно отмечены await, и мы должны строить код так, чтобы его можно было проверять в контролируемых условиях.

Контракт транспорта: что именно мы мокируем

Прежде чем писать мок, нужно очень чётко понять: какой контракт мы мокируем. Мы не «мокируем URLSession», не «мокируем API целиком» и точно не «мокируем JSONDecoder». Мы мокируем транспорт, то есть «отправь запрос — верни байты и HTTP‑ответ (или ошибку)».

Обычно контракт транспорта в нашем курсе выглядит примерно так: вход — URLRequest, выход — (Data, HTTPURLResponse), плюс возможность бросить ошибку. Это почти идеальный контракт для мока: минимум зависимостей, типы стандартные, поведение легко запрограммировать.

Пример (фиксируем форму протокола):


import Foundation

protocol HTTPClient {
    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}

Почему это удобно? Потому что выше по слою (например, в ApiClient) мы работаем только с HTTPClient, а значит можем подставить настоящую сеть или детерминированный мок — и остальной код даже не заметит подмены.

Минимальная реализация: один заранее заданный ответ

Теперь сделаем самый маленький «пульт управления интернетом». Он должен уметь: принять запрос и вернуть то, что мы заранее положили в мок как результат.

Начнём с простейшей модели: у мока есть одно поле result, в котором лежит либо успех (Data, HTTPURLResponse), либо ошибка. И каждый вызов send возвращает это значение.

import Foundation

final class MockHTTPClient: HTTPClient {
    var result: Result<(Data, HTTPURLResponse), Error>

    init(result: Result<(Data, HTTPURLResponse), Error>) {
        self.result = result
    }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        try result.get()
    }
}

Да, это выглядит слишком просто. И именно в этом прелесть: чем проще мок, тем меньше шанс «написать баг в тестах», что является отдельным жанром страданий.

Запоминание запросов: receivedRequests

Если мок только возвращает результат, мы можем проверить «что получилось». Но часто важно проверить ещё и «что мы отправили». Например: правильный URL, правильный HTTP‑метод, нужные заголовки, query‑параметры и т.д.

Поэтому добавим в мок «журнал посещений»: массив receivedRequests. И сделаем его private(set), чтобы снаружи можно было читать, но нельзя было случайно перезаписать.

import Foundation

final class MockHTTPClient: HTTPClient {
    private(set) var receivedRequests: [URLRequest] = []
    var result: Result<(Data, HTTPURLResponse), Error>

    init(result: Result<(Data, HTTPURLResponse), Error>) {
        self.result = result
    }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        receivedRequests.append(request)
        return try result.get()
    }
}

Теперь мок умеет две вещи: «вернуть ответ» и «запомнить запрос». Для большинства unit‑тестов сетевого слоя этого достаточно.

3. Тестовые сценарии: успех, транспортная ошибка и «битый JSON»

Сценарий успеха: 2xx + валидный JSON

Сценарий успеха — самый приятный: запрос ушёл, сервер ответил 200, тело ответа — корректный JSON.

Но есть важный нюанс: мок — это транспорт, он не декодирует. Значит, чтобы «проверить успешный сценарий» на уровне ApiClient, мы должны вернуть такие Data, которые действительно можно декодировать в ожидаемый DTO.

Для примера возьмём простую DTO‑модель. Пусть сервер возвращает JSON с одним полем message.

import Foundation

struct PingDTO: Decodable {
    let message: String
}

Теперь нам нужно подготовить Data с валидным JSON. Для простоты — руками, строкой.

import Foundation

let okJSON = #"{"message":"pong"}"#
let okData = Data(okJSON.utf8)

Остался HTTPURLResponse. Его нельзя создать «просто так» без проверки, потому что это failable initializer (может вернуть nil). Поэтому в тестовых утилитах обычно делают маленький helper: если он упал — это проблема теста, а не проблема вашего кода.

import Foundation

func makeHTTPResponse(url: URL, statusCode: Int) -> HTTPURLResponse {
    guard let r = HTTPURLResponse(url: url, statusCode: statusCode,
                                  httpVersion: nil, headerFields: nil) else {
        fatalError("Failed to build HTTPURLResponse")
    }
    return r
}

Теперь собираем успешный результат для мока:

import Foundation

let url = URL(string: "https://example.com/ping")!
let response = makeHTTPResponse(url: url, statusCode: 200)

let mock = MockHTTPClient(result: .success((okData, response)))

На этом месте часто возникает «честный вопрос новичка»: «А где здесь ApiClient?». Мы его не пишем заново в этой лекции, потому что он относится к интерпретации, а наша цель — научиться управлять транспортом. Но вы уже видите, что мок готов отдавать «идеальный ответ сервера», и это позволит выше по слою получить корректный PingDTO.

Сценарий ошибки: запрос не выполнился (транспортная ошибка)

Теперь менее приятный сценарий: запроса как будто «не случилось». Причины в жизни бывают разные: нет сети, DNS не резолвится, TLS ругается, сервер недоступен и так далее. На уровне транспорта это почти всегда выглядит одинаково: метод send бросает ошибку.

И вот здесь MockHTTPClient особенно полезен: он позволяет вам сымитировать ошибку ровно тогда, когда вам нужно, без выключения Wi‑Fi в офисе.

В качестве примера используем URLError. Это стандартная ошибка Foundation, и её удобно применять как «типичный сетевой провал».

import Foundation

let url = URL(string: "https://example.com/ping")!
let error = URLError(.notConnectedToInternet)

let mock = MockHTTPClient(result: .failure(error))

Что важно концептуально: мы не превращаем все ошибки в одну строку вроде "Network failed". Мы сохраняем тип ошибки, чтобы выше по слою можно было корректно решить, что делать: показать пользователю «проверьте интернет», залогировать детали или попробовать повторить запрос.

Сценарий «битый JSON»: сеть успешна, но данные — мусор

Один из самых коварных сценариев: «Статус 200, но декодирование падает». И новички часто в этот момент говорят: «Ну раз 200, значит всё ок». Увы, 200 означает только «сервер считает, что всё ок». Ваш код может считать иначе — особенно если тело ответа не соответствует ожиданиям.

Важно: это не транспортная ошибка. Транспорт отлично доставил данные. Значит, мок должен вернуть успех (Data, HTTPURLResponse), но Data должны быть такими, чтобы декодирование сломалось.

Есть несколько способов сделать «битый JSON».

Самый прямой: вернуть строку, которая вообще не JSON.

import Foundation

let badJSON = "this is not json at all"
let badData = Data(badJSON.utf8)

Второй способ (ещё более жизненный): вернуть JSON, но «не той формы». Например, сервер вернул {"msg":"pong"}, а мы ждём {"message":"pong"}. Это будет валидный JSON, но декодирование в PingDTO упадёт, потому что ключ message не найден.

import Foundation

let wrongShapeJSON = #"{"msg":"pong"}"#
let wrongShapeData = Data(wrongShapeJSON.utf8)

Собираем мок: статус 200, но тело ответа «ломает модель».

import Foundation

let url = URL(string: "https://example.com/ping")!
let response = makeHTTPResponse(url: url, statusCode: 200)

let mock = MockHTTPClient(result: .success((wrongShapeData, response)))

Почему это важно именно для тестов? Потому что вы хотите гарантировать: «Если JSON не совпал с DTO, мы возвращаем правильную ошибку (например, .decoding(...)) и не выдаём пользователю абракадабру». И без мока вы будете пытаться «поймать» такой ответ от реального сервера, что обычно заканчивается мыслью «Ну он же не должен так отвечать…». А сервер, как известно, никому ничего не должен.

4. Несколько запросов подряд: очередь результатов

В реальном коде ApiClient или сервис поверх него может делать несколько запросов: сначала «получить список», потом «получить детали», потом «скачать картинку котика». Если у мока всего один result, он будет возвращать одно и то же на каждый запрос, а это ограничивает сценарии тестирования.

Решение: хранить не один результат, а очередь результатов. Каждый вызов send берёт первый элемент очереди и удаляет его. Так мы можем запрограммировать: «первый запрос успешный, второй — 500, третий — битый JSON».

import Foundation

final class MockHTTPClient: HTTPClient {
    private(set) var receivedRequests: [URLRequest] = []
    var results: [Result<(Data, HTTPURLResponse), Error>]

    init(results: [Result<(Data, HTTPURLResponse), Error>]) {
        self.results = results
    }

    func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
        receivedRequests.append(request)
        return try results.removeFirst().get()
    }
}

Здесь важно понимать, что removeFirst() у массива не самый дешёвый по сложности метод, но для unit‑тестов это вообще не проблема: тесты не про производительность, а про ясность и точность.

Защита от пустой очереди результатов

Чтобы не получить «внезапное падение» из‑за пустой очереди, иногда делают защиту и fatalError с внятным сообщением. В тестах это нормально: если вы забыли подготовить результат — это ошибка теста, а не ошибка приложения.

import Foundation

func popNext(_ results: inout [Int]) -> Int {
    guard !results.isEmpty else { fatalError("No more results configured") }
    return results.removeFirst()
}

(Да, здесь пример на Int, чтобы не раздувать код: идея та же.)

Небольшая схема: где живёт мок в архитектуре

Когда вы держите в голове роли, код пишется спокойнее. Вот как выглядит «позиция» MockHTTPClient:

flowchart LR
    A["Ваш код ApiClient / Service"] -->|send URLRequest| B["HTTPClient (protocol)"]
    B --> C["MockHTTPClient (tests)"]
    B --> D["URLSessionHTTPClient (production)"]
    C -->|"returns deterministic (Data, HTTPURLResponse)"| A
    D -->|does real network| A

Смысл схемы в том, что ApiClient вообще не знает, кто там снизу: мок, реальный URLSession, или (если вы совсем смелые) голубиная почта.

5. Типичные ошибки при реализации MockHTTPClient

Ошибка №1: мок «слишком умный» и начинает проверять statusCode или декодировать JSON.
Это очень частая ловушка: хочется «сделать удобно» и засунуть в мок больше логики. Но тогда вы случайно начинаете тестировать мок, а не свой ApiClient. У транспорта одна работа: принять URLRequest и вернуть сырьё (байты и HTTP‑ответ) или ошибку. Всё остальное — выше по слою.

Ошибка №2: мок не записывает входящие запросы, и вы теряете половину пользы теста.
Можно протестировать, что «получили DTO», но не заметить, что ходили на неправильный URL или забыли заголовок. В результате код «как-то работает», пока сервер терпит. Как только сервер станет строже, вы получите баг, который можно было поймать простейшей проверкой receivedRequests.

Ошибка №3: мок возвращает Data() (пустые байты) в «успешном» сценарии и ожидает, что декодирование пройдёт.
Пустые байты — это почти всегда ошибка декодирования (если вы не декодируете пустой JSON‑массив или что-то подобное). Если вы тестируете успех, дайте валидный JSON. Если тестируете «битый JSON», дайте намеренно неправильные данные — но называйте вещи своими именами.

Ошибка №4: очередь результатов заканчивается раньше, чем реальные вызовы send, и тест падает «в непонятном месте».
Если вы используете results.removeFirst(), очень легко забыть, сколько запросов делает код. В итоге тест падает не с красивым assert’ом, а с чем-то вроде «Index out of range». Лучше падать с fatalError("No more results configured"), чтобы сразу было видно: вы забыли запрограммировать очередной ответ.

Ошибка №5: мок хранит состояние так, что его сложно переиспользовать между тестами.
Например, вы создаёте один MockHTTPClient глобально, гоняете через него десяток тестов, а потом удивляетесь, почему receivedRequests вдруг содержит запросы из прошлого теста. В тестах состояние должно быть максимально локальным: один тест — один мок (или сброс состояния перед каждым тестом), иначе вы случайно тестируете «порядок запуска тестов».

1
Задача
Swift SELF, 66 уровень, 0 лекция
Недоступна
Журнал запросов
Журнал запросов
1
Задача
Swift SELF, 66 уровень, 0 лекция
Недоступна
Очередь ответов
Очередь ответов
1
Задача
Swift SELF, 66 уровень, 0 лекция
Недоступна
Три исхода
Три исхода
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ