Разработка Unit-тестов для iOS-приложения (XCTest)
Типичная ситуация: ViewModel разрастается до 500 строк, в ней смешана бизнес-логика, форматирование данных и прямые вызовы сети. Добавляешь новую фичу — ломается что-то в старом потоке. Откатываешь — снова работает, но причина неясна. Юнит-тесты на XCTest — это не про «покрытие ради покрытия», а про возможность рефакторить без страха.
Что тестируем и как
Бизнес-логика — главный приоритет. 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)
}
Асинхронный код — второй по важности блок. Современный Swift с async/await тестируется нативно: XCTest поддерживает async тест-функции с iOS 15+ и Xcode 13+. Для Combine-based кода — XCTestExpectation + sink.
Граничные случаи — то, что реально падает в продакшене: пустой массив, nil-значение, строка с юникодом, дата в другом timezone. Не «happy path», а именно edge cases.
Архитектура, удобная для тестирования
XCTest-тесты пишутся просто, когда архитектура предполагает инверсию зависимостей. MVVM с DI через инициализатор, Clean Architecture с UseCase — тестируются прямо. Singleton-ы и статические методы — нет. Если проект не использует DI, часть работы — рефакторинг перед написанием тестов.
Частая проблема: ViewModel обращается к UserDefaults напрямую, к Date() напрямую. Оба нужно обернуть в протоколы и инжектировать — иначе тесты будут зависеть от системного состояния и времени запуска.
Тестирование Combine и async/await
// 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)
}
CI-интеграция
Тесты запускаем на каждый PR через xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'. Матрица версий iOS фиксируется в GitHub Actions / Bitrise конфигурации. Code coverage репортируем через Xcode Coverage Report или xcov gem. Цель покрытия — не 100%, а логики: ViewModel/Interactor/UseCase должны быть покрыты на 80%+, UI — не трогаем юнит-тестами.
Типичные ошибки в iOS unit-тестах
-
@testable importбез флага-enable-testingв build settings — импорт не работает в CI -
Тесты, зависящие от порядка —
XCTestне гарантирует порядок выполнения, каждый тест должен быть изолирован черезsetUp()/tearDown() -
Реальные сетевые запросы в тестах — тест становится нестабильным и медленным. Всегда мокируем через
URLProtocolsubclass илиURLSessionс кастомнымURLSessionConfiguration
Процесс работы
Аудит существующего кода → выделение тестируемых компонентов → при необходимости минимальный рефакторинг для DI → написание тестов → интеграция в CI. Отчёт по покрытию после завершения.
Срок: 3–5 дней в зависимости от объёма кодовой базы и текущего уровня архитектурной изолированности компонентов.







