Как только удалённая сборка начинает зависеть от реального тестового API, один и тот же коммит может пройти утром и завершиться ошибкой днём: тестовые данные изменились, токен истёк, шлюз ограничил частоту запросов или API временно замедлился. Хуже того, от сбоя часто остаётся лишь ошибка декодирования, по которой невозможно понять, что именно получил клиент. Решение заключается не в дополнительных повторах запросов, а в разделении проверок сетевого слоя на две категории: большинство регрессионных тестов должно использовать локальные фиксированные ответы, а к реальному окружению следует обращаться только в небольшом наборе контрактных тестов.
Определите границы офлайн-тестирования
URLProtocol встраивается в цепочку обработки запросов URLSession и позволяет вернуть пользовательский ответ до фактической отправки запроса. Он подходит для проверки четырёх аспектов: правильности HTTP-метода и пути, полноты заголовков и тела запроса, корректности декодирования моделей и преобразования кодов состояния в явные ошибки на уровне бизнес-логики.
При этом URLProtocol не подтверждает текущую доступность реального сервиса и не выявляет изменения в конфигурации шлюза. Поэтому тесты рекомендуется разделить на уровни:
| Уровень | Источник данных | Основные проверки | Частота запуска |
|---|---|---|---|
| Модульная регрессия | Локальные JSON-фикстуры | Декодирование и преобразование ошибок | При каждом коммите |
| Регрессия сетевого слоя | URLProtocol | Полный цикл запроса и ответа | При каждом коммите |
| Контрактные тесты | Контролируемый тестовый API | Контракты полей и аутентификации | По расписанию или перед выпуском |
Цель офлайн-регрессии — устранить посторонние колебания, а не имитировать запрос, который всегда завершается успешно. Ответы 404, 429 и 500, пустые ответы и повреждённый JSON также должны стать фиксированными тестовыми сценариями.
Внедрите отдельный URLSession
Не регистрируйте протокол глобально в тестах и не позволяйте прикладному коду обращаться напрямую к URLSession.shared. Передавайте сессию в APIClient через внедрение зависимостей: в рабочем окружении используйте конфигурацию по умолчанию, а в тестовом — ephemeral. Так тесты не будут наследовать дисковый кеш, Cookie и состояние старых соединений.
final class StubURLProtocol: URLProtocol {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool {
request.url?.host == "api.test.invalid"
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let handler = Self.handler else {
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
func makeTestSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [StubURLProtocol.self]
configuration.timeoutIntervalForRequest = 3
return URLSession(configuration: configuration)
}
Метод canInit должен ограничивать перехват по имени хоста или пользовательскому заголовку запроса. Если он безусловно возвращает true, под перехват могут попасть и другие экземпляры URLSession в тестовом процессе, из-за чего локализовать ошибку станет сложнее.
Поддерживайте фикстуры ответов как код
Фикстуры рекомендуется хранить в тестовом Target по пути Fixtures/API/v1/, называя их по ресурсу и сценарию, например projects-success.json, projects-empty.json и projects-malformed.json. Не сохраняйте полные дампы трафика с динамическими токенами, адресами электронной почты или внутренними URL.
Перед коммитом можно единообразно отсортировать ключи и проверить синтаксис:
find Tests/Fixtures -name '*.json' -print0 |
while IFS= read -r -d '' file; do
tmp="${file}.tmp"
jq -S . "$file" > "$tmp" && mv "$tmp" "$file"
done
Даже в успешных сценариях необходимо проверять сам запрос. Если проверять только итоговую модель, можно не заметить, что клиент отправляет POST вместо GET, не передаёт заголовок версии или дважды кодирует параметры запроса.
StubURLProtocol.handler = { request in
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/v1/projects")
XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
let data = try Data(contentsOf: fixtureURL("projects-success"))
let response = HTTPURLResponse(
url: try XCTUnwrap(request.url),
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
Проверяйте каждую категорию ошибок отдельно
Как минимум следует покрыть сбои транспорта, HTTP и содержимого. Сбой транспорта можно имитировать выбросом URLError(.timedOut). При HTTP-сбое нужно возвращать реальный код состояния и структурированное тело ошибки. Для сбоя содержимого верните 200, но передайте JSON с отсутствующими полями, неверными типами или обрезанными данными. На уровне бизнес-логики все три случая не должны превращаться в одну и ту же «неизвестную ошибку».
В тестах тайм-аута не нужно действительно ждать десятки секунд. Пусть handler сразу выбрасывает соответствующую ошибку, после чего проверьте, переходит ли модель интерфейса в состояние, допускающее повторную попытку. Для ответа 429 также проверьте, считывает ли клиент Retry-After, но не выполняйте реальную задержку в тесте. Вынесите расчёт интервала повтора в чистую функцию и отдельно проверьте её входные данные и результат.
После каждого теста присваивайте handler значение nil. Если набор тестов выполняется параллельно, один статический handler может быть перезаписан другим тестовым сценарием. Самый надёжный исходный вариант — последовательный запуск этого набора. Если параллельное выполнение действительно необходимо, используйте отображение идентификаторов запросов на замыкания ответов и защищайте реестр блокировкой или actor.
Обеспечьте стабильный запуск на облачном Mac
На облачном Mac от DplyMini сначала зафиксируйте выбранную версию Xcode, а затем каждый раз запускайте один и тот же workspace, scheme и план тестирования. Выделенная физическая машина не является виртуальной, однако DerivedData всё равно следует явно изолировать, чтобы разные ветки не использовали общие промежуточные артефакты.
set -euo pipefail
sudo xcode-select -s /Applications/Xcode.app
rm -rf "$PWD/.derived-data"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-testPlan NetworkRegression \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-derivedDataPath "$PWD/.derived-data" \
-resultBundlePath "$PWD/TestResults/NetworkRegression.xcresult"
Перед первым запуском выполните xcodebuild -showdestinations и проверьте названия симуляторов, доступных в текущем окружении. Не копируйте название устройства с личного компьютера напрямую в сценарий. При сбое сохраняйте xcresult, журналы тестов и соответствующую версию фикстур, а не только последние несколько строк консольного вывода.
Итоговая приёмка должна подтвердить четыре условия: офлайн-набор завершается даже при отключённом внешнем сетевом доступе; последовательные запуски дают одинаковый результат; изменения фикстур хорошо видны при проверке кода; сбой реального контрактного теста не мешает разработчикам определить, возникла ли регрессия в логике клиента. Только при выполнении этих четырёх условий сетевые тесты перестают зависеть от удачного состояния API и становятся поддерживаемым инженерным активом.
Часто задаваемые вопросы
Могут ли тесты URLProtocol полностью заменить интеграцию с реальным API?
Нет. Они надёжно проверяют формирование запросов, декодирование и обработку ошибок, но отдельный небольшой набор контрактных тестов должен проверять шлюз, авторизацию и актуальную схему сервера.
Зачем передавать в тестируемый код отдельный URLSession?
Сеанс с ephemeral-конфигурацией ограничивает область перехвата и исключает влияние общих cookie, кэша и глобальной регистрации протокола на другие тесты.
Как избежать конфликтов общего handler при параллельных тестах?
Простой набор следует запускать последовательно. Для параллельного режима ответы нужно хранить по идентификатору запроса, защищая реестр блокировкой или actor.
Выберите срок аренды облачного Mac в соответствии с длительностью задачи
Две конфигурации M4 доступны для аренды посуточно, понедельно, помесячно или поквартально; актуальные сведения об узлах и доступности отображаются в консоли в реальном времени.