Розробка 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.
Граничні випадки — то, що реально падає в production: порожний масив, 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 днів залежно від обсягу кодової бази та поточного рівня архітектурної ізольованості компонентів.







