Продвинутое тестирование в Swift: Unit, Integration и E2E
Тестирование — это не просто проверка, а фундамент надежного кода. На продвинутом уровне разработчику Swift нужно не просто писать тесты, а грамотно выстраивать пирамиду тестирования: от быстрых unit-тестов до сложных end-to-end (e2e) сценариев. В этой статье мы разберем продвинутые техники для iOS/macOS-проектов: работу с зависимостями, интеграционные тесты CoreData и автоматизацию UI.
1. Unit-тесты: управление зависимостями и моки
На продвинутом уровне unit-тесты должны быть изолированы. Ключевой прием — Dependency Injection (DI). Вместо того чтобы тестировать реальный сетевой слой или базу данных, мы подменяем их моками.
Пример: протокол и DI
protocol NetworkServiceProtocol {
func fetchData(completion: @escaping (Result<String, Error>) -> Void)
}
class ViewModel {
private let networkService: NetworkServiceProtocol
init(networkService: NetworkServiceProtocol) {
self.networkService = networkService
}
func loadData() {
networkService.fetchData { result in
// обработка
}
}
}
// Мок для тестов
class MockNetworkService: NetworkServiceProtocol {
var shouldReturnError = false
func fetchData(completion: @escaping (Result<String, Error>) -> Void) {
if shouldReturnError {
completion(.failure(NSError(domain: "test", code: -1)))
} else {
completion(.success("Mock data"))
}
}
}
В тесте вы создаете MockNetworkService и передаете его в ViewModel. Это позволяет проверять сценарии ошибок и успеха без реального API.
XCTestExpectation для асинхронности
func testViewModelLoadDataSuccess() {
let mock = MockNetworkService()
let viewModel = ViewModel(networkService: mock)
let expectation = self.expectation(description: "Data loaded")
viewModel.loadData()
// Симулируем асинхронный вызов
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
// Проверяем состояние ViewModel
}
2. Интеграционные тесты: CoreData и работа с реальными данными
Интеграционные тесты проверяют взаимодействие компонентов. Для CoreData мы используем in-memory контейнер, чтобы не загрязнять основную базу.
Настройка in-memory CoreData
class CoreDataTestStack {
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyModel")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Failed to load store: \(error)")
}
}
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
}
Пример интеграционного теста
func testSaveAndFetchUser() {
let stack = CoreDataTestStack()
let repository = UserRepository(context: stack.context)
// Создаем пользователя
let user = User(context: stack.context)
user.name = "Test User"
user.age = 30
try? stack.context.save()
// Проверяем, что данные сохранились
let fetchRequest: NSFetchRequest<User> = User.fetchRequest()
let users = try? stack.context.fetch(fetchRequest)
XCTAssertEqual(users?.count, 1)
XCTAssertEqual(users?.first?.name, "Test User")
}
Важно: после каждого теста очищайте контекст, чтобы тесты не влияли друг на друга.
3. E2E-тесты на XCUITest: сценарии и ожидания
End-to-end тесты имитируют пользовательские действия. На продвинутом уровне мы используем кастомные ожидания и Page Object Pattern.
Page Object Pattern
class LoginPage {
let app: XCUIApplication
init(app: XCUIApplication) {
self.app = app
}
var loginTextField: XCUIElement {
return app.textFields["loginField"]
}
var passwordTextField: XCUIElement {
return app.secureTextFields["passwordField"]
}
var loginButton: XCUIElement {
return app.buttons["loginButton"]
}
func login(username: String, password: String) {
loginTextField.tap()
loginTextField.typeText(username)
passwordTextField.tap()
passwordTextField.typeText(password)
loginButton.tap()
}
}
Тест с ожиданием результата
func testSuccessfulLogin() {
let app = XCUIApplication()
app.launch()
let loginPage = LoginPage(app: app)
loginPage.login(username: "test@example.com", password: "password123")
// Ожидаем появления экрана профиля
let profileLabel = app.staticTexts["profileLabel"]
let exists = profileLabel.waitForExistence(timeout: 5)
XCTAssertTrue(exists, "Профиль не появился после логина")
}
Совет: используйте accessibilityIdentifier для элементов UI, чтобы тесты не ломались при изменении текста.
4. Продвинутые техники: Code Coverage и параллельный запуск
Настройте Code Coverage в Xcode (Product → Scheme → Edit Scheme → Test → Options → Gather coverage data). Стремитесь к покрытию 80%+ для критических модулей.
Для ускорения тестов используйте параллельный запуск в Xcode Cloud или CI/CD. Разделите тесты на группы: unit (быстрые, без UI) и UI (медленные).
Пример настройки fastlane для параллельного запуска:
lane :tests do
scan(
scheme: "MyApp",
parallel_testrun_count: 4,
code_coverage: true
)
end
Заключение
Продвинутое тестирование в Swift — это комбинация правильной архитектуры (DI, протоколы), изолированных тестов (in-memory CoreData) и автоматизации UI (Page Object). Внедряйте эти практики постепенно: начните с unit-тестов на критическую логику, добавьте интеграционные для CoreData и завершите e2e для ключевых пользовательских сценариев. Помните, что тесты — это инвестиция в стабильность приложения и скорость разработки.