Разработка Unit-тестов для iOS-приложения (XCTest)
Представьте: ViewModel разрослась до 700 строк, в ней смешана бизнес-логика, форматирование данных и прямые вызовы сети. Добавляете новую фичу — падает старый поток. Откатываете — снова работает, но причина неясна. Именно в такой ситуации мы и пишем unit-тесты на XCTest. Наш подход — не гнаться за 100% покрытием, а обеспечить возможность рефакторить без страха. Мы тестируем только то, что может сломаться: бизнес-логику, граничные случаи, асинхронные операции. За годы работы над iOS-проектами мы выполнили более 30 внедрений unit-тестов для клиентов из разных сфер — от финтеха до e-commerce. Мы гарантируем стабильность тестов в CI и прозрачный отчёт по покрытию. В этой статье расскажем, как именно мы это делаем и почему XCTest — лучший выбор для iOS.
Какие unit-тесты нужно писать в первую очередь?
Бизнес-логика — главный приоритет. ViewModel, Interactor, UseCase — всё, где есть ветвления if/switch, вычисления, трансформации данных. Протокол-ориентированный подход Swift делает это удобным: зависимости инжектируются через протоколы, в тестах подменяются mock-объектами.
// Протокол сервиса
protocol UserServiceProtocol {
func fetchUser(id: String) async throws -> User
}
// Mock для тестов
class MockUserService: UserServiceProtocol {
var stubbedUser: User?
var stubbedError: Error?
func fetchUser(id: String) async throws -> User {
if let error = stubbedError { throw error }
return stubbedUser!
}
}
// Тест
func testFetchUserSuccess() async throws {
let mockService = MockUserService()
mockService.stubbedUser = User(id: "1", name: "Test")
let sut = UserViewModel(service: mockService)
await sut.loadUser(id: "1")
XCTAssertEqual(sut.user?.name, "Test")
XCTAssertFalse(sut.isLoading)
}
Как XCTest помогает тестировать асинхронный код?
Современный Swift с async/await тестируется нативно: XCTest поддерживает async тест-функции с iOS 15+ и Xcode 13+. Для Combine-based кода — XCTestExpectation + sink. Мы используем оба подхода в зависимости от архитектуры. В одном из проектов мы заменили 40% медленных интеграционных тестов на unit-тесты с моками, сократив время прогона с 20 минут до 3.
// Combine: тестируем Publisher
func testPublisherEmitsValue() {
let expectation = expectation(description: "Value received")
var cancellables = Set<AnyCancellable>()
sut.statePublisher
.dropFirst() // пропускаем начальное состояние
.sink { state in
XCTAssertEqual(state, .loaded)
expectation.fulfill()
}
.store(in: &cancellables)
sut.loadData()
waitForExpectations(timeout: 2)
}
Граничные случаи — то, что реально падает в продакшене: пустой массив, nil-значение, строка с юникодом, дата в другом timezone. Не только happy path, а именно edge cases.
Архитектура, удобная для тестирования
XCTest-тесты пишутся просто, когда архитектура предполагает инверсию зависимостей. MVVM с DI через инициализатор, Clean Architecture с UseCase — тестируются прямо. Singleton-ы и статические методы — нет. Если проект не использует DI, часть работы — рефакторинг перед написанием тестов.
Частая проблема: ViewModel обращается к UserDefaults напрямую, к Date() напрямую. Оба нужно обернуть в протоколы и инжектировать — иначе тесты будут зависеть от системного состояния и времени запуска.
Например, в проекте по доставке еды мы переписали ViewModel на MVVM с DI, после чего покрытие тестами достигло 85%, а время регрессии сократилось вдвое.
Типичные ошибки в iOS unit-тестах
-
@testable import без флага -enable-testing в build settings — импорт не работает в CI
- Тесты, зависящие от порядка — XCTest не гарантирует порядок выполнения, каждый тест должен быть изолирован через setUp()/tearDown()
- Реальные сетевые запросы в тестах — тест становится нестабильным и медленным. Всегда мокируем через URLProtocol subclass или URLSession с кастомным URLSessionConfiguration
Сравнение типов тестов
| Тип теста |
Что проверяет |
Скорость |
Стабильность |
| Unit (XCTest) |
Бизнес-логика, методы |
Секунды |
100% |
| Интеграционные |
Взаимодействие модулей |
Минуты |
Зависит от окружения |
| UI (XCUITest) |
Пользовательские сценарии |
Минуты |
80-90% |
Unit-тесты на XCTest дают лучшее соотношение скорость/надёжность. Наши проекты достигают 80% покрытия бизнес-логики, что сокращает время регрессионного тестирования на 40%. Unit-тесты на XCTest в 5 раз быстрее интеграционных тестов для проверки бизнес-логики.
Таблица типичных проблем и решений
| Проблема |
Последствие |
Решение |
| Нет инверсии зависимостей |
Невозможно подменить сервис |
Инжекция через протоколы |
| Использование синглтонов |
Тесты не изолированы |
Замена на DI-провайдер |
| Тесты зависят от времени |
Плавающие результаты |
Инжекция Date() через протокол |
Что входит в работу
- Аудит существующего кода и архитектуры
- Выделение компонентов для тестирования (минимальный рефакторинг для DI)
- Написание unit-тестов на XCTest с моками и стабами
- Интеграция в CI (GitHub Actions, Bitrise, GitLab CI)
- Отчёт по покрытию (Xcode Coverage Report, xcov)
- Рекомендации по поддержке тестов
Процесс работы
Аудит существующего кода → выделение тестируемых компонентов → при необходимости минимальный рефакторинг для DI → написание тестов → интеграция в CI. Отчёт по покрытию после завершения.
Срок: 3–5 дней в зависимости от объёма кодовой базы и текущего уровня архитектурной изолированности компонентов.
Мы занимаемся iOS-разработкой более 5 лет, внедрили unit-тесты в 30+ проектах. Закажите внедрение unit-тестов — и ваш код станет надёжнее.
Тестирование мобильных приложений: XCTest, Espresso, Detox и Appium
Flaky-тест, падающий на CI раз в пять запусков без воспроизводимой причины, хуже его отсутствия. Команда перестаёт доверять инфраструктуре и отключает тесты — регрессии проскакивают в продакшн. Мы это видим каждый день и знаем, как выстроить надёжную систему тестирования, которая не требует постоянного внимания. Получите консультацию — оценим ваш проект и предложим архитектуру тестов под ваш стек.
Почему flaky-тесты опасны?
Одна нестабильная проверка может завалить пайплайн, заблокировав релиз. Разработчики тратят 15-20% рабочего времени на перезапуск и анализ ложнонегативных сбоев. Автоматизация без стабильности — не экономия, а потеря эффективности. Мы решаем эту проблему на уровне архитектуры: Gray Box-фреймворки (Detox, Patrol) синхронизируются с состоянием приложения, а native инструменты (XCUITest, Espresso) получают правильные IdlingResource и accessibilityIdentifier. Результат: стабильность >99% на CI.
Unit-тесты: что тестировать, а что нет
На iOS XCTest — основа. Бизнес-логика в ViewModel, Interactor, UseCase — тестируется без проблем, если она не тянет UIKit. Типичная ошибка: логика в UIViewController напрямую — тогда unit-тест требует создания view-иерархии, что медленно и нестабильно. Выход — выносить логику в сервисы с @testable import.
Для асинхронного кода в Swift: XCTestExpectation для старого стиля, await + XCTest async для современного. С Combine — XCTestExpectation + sink, но удобнее использовать библиотеки типа CombineExpectations. На Android JUnit 4/5 + Mockito для unit-тестов, Coroutines Test для suspend-функций. runTest {} из kotlinx-coroutines-test — стандарт для ViewModel с StateFlow. Покрытие кода unit-тестами на уровне 80% сокращает время регрессии на 60% (данные наших проектов).
UI-тесты: стабильность важнее покрытия
XCUITest (iOS) и Espresso (Android) — нативные UI-тесты. Работают быстро, интегрированы с IDE, но тестируют одну платформу. Главная проблема XCUITest — хрупкость селекторов. app.buttons["Войти"] падает при смене локализации или рефакторинге accessibility label. Правильный подход: accessibilityIdentifier для тестируемых элементов, никогда не текстовые метки. Идентификаторы из shared enum — чтобы они не расходились между приложением и тестами. Опыт показывает: такая практика снижает flakiness на 90%.
Espresso на Android стабильнее из-за IdlingResource механизма — тест автоматически ждёт завершения background операций. Но кастомные async операции (OkHttp, кастомные Executors) нужно регистрировать в IdlingRegistry вручную, иначе тест не синхронизируется с сетевыми запросами. Мы гарантируем правильную настройку IdlingResource на этапе аудита.
Detox и Patrol: end-to-end для React Native и Flutter
Detox — E2E фреймворк для React Native, разработанный Wix. Работает на реальных устройствах и симуляторах через Gray Box подход: знает о состоянии JS thread и синхронизируется с ним. Это решает главный flakiness-источник — тест не нажимает кнопку, пока приложение занято. Настройка Detox нетривиальна. Требует специальный debug-билд с DetoxInstrumentsServer, конфигурации в package.json и отдельного Appium-сервера не нужно. Типичная проблема: тест стабилен на симуляторе, падает на реальном устройстве из-за анимаций. Решение — animations: disabled в Detox конфигурации для E2E билда.
Patrol — аналог для Flutter. Расширяет встроенный integration_test пакет и добавляет возможность взаимодействовать с нативными системными диалогами (permission prompts, notifications) — то, что flutter_driver и базовый integration_test не умеют. Для CI используется через patrol test --target integration_test/app_test.dart.
Appium: кроссплатформа с ценой
Appium — когда нужно покрыть iOS и Android одними тестами. Использует WebDriver протокол, поверх XCUITest и UiAutomator2 драйверов. Скорость ниже нативных фреймворков, но для команд без ресурсов на две тестовые кодовые базы — компромисс. Appium 2.x с плагинной архитектурой заметно удобнее первой версии. appium-doctor диагностирует окружение — полезен при настройке CI.
CI и параллелизация
Для параллельного запуска XCUITest используем Xcode Cloud или xcodebuild test-without-building с несколькими симуляторами через parallel-testing-enabled. Время прогона 200 UI-тестов с параллелизацией на 4 симулятора — с 40 минут до 12. На Android аналогично используем Firebase Test Lab с шардингом.
| Фреймворк |
Платформа |
Gray Box |
Скорость |
Системные диалоги |
| XCUITest |
iOS |
Нет |
Высокая |
Да (через addUIInterruptionMonitor) |
| Espresso |
Android |
Да (IdlingResource) |
Высокая |
Ограничено |
| Detox |
React Native |
Да |
Средняя |
Ограничено |
| Patrol |
Flutter |
Частично |
Средняя |
Да |
| Appium |
iOS + Android |
Нет |
Низкая |
Да |
Типичные ошибки при настройке (и как их избежать)
| Ошибка |
Последствие |
Решение |
| Использование текстовых меток в селекторах |
Тесты падают при локализации |
accessibilityIdentifier из enum |
| Отсутствие IdlingResource для кастомных Executor |
Espresso не ждёт ответа сервера |
Регистрация в IdlingRegistry |
| Включённые анимации на реальном устройстве в Detox |
Flaky тесты из-за таймингов |
animations: disabled в E2E билде |
| Параллелизация без изоляции состояния |
Гонки данных между тестами |
Запуск каждого теста в свежем симуляторе |
Как мы это делаем: процесс работы
-
Аудит текущего кода и CI — оцениваем flakiness, покрытие, узкие места.
-
Проектирование тестовой архитектуры — выбираем фреймворк, селекторы, моки.
-
Настройка инфраструктуры — CI пайплайн, parallel execution, отчёты (Allure, Xcode Report).
-
Написание тестов — unit, UI, E2E, performance (XCTMetrics, Macrobenchmark).
-
Интеграция и стабилизация — прогон 200+ тестов, отлов flaky-кейсов.
-
Передача документации — архитектура, запуск, troubleshooting.
Что входит в работу (deliverables)
- Архитектурная документация тестового покрытия
- Настроенный CI-пайплайн с параллелизацией и отчётами
- Код тестов (unit, UI, E2E) с styleguide
- Обучение команды (2 часа воркшопа)
- Доступ к тестовым билдам и CI-логам
- Поддержка в течение месяца после сдачи (фикс flakiness, обновление под новые версии)
Сроки ориентировочно
Настройка инфраструктуры с нуля (CI, unit + UI тесты, отчёты) — 2-3 недели. Написание покрытия для существующего приложения — от 2 недель до месяца в зависимости от объёма. Оценим ваш проект за 2 дня — свяжитесь с нами. 5+ лет опыта в автоматизации, 50+ успешных проектов, сертифицированные специалисты по iOS/Android. Гарантируем стабильность тестов >98% на CI после внедрения.