Тестирование в Swift: Unit, Integration, E2E — от теории к практике
Качественное тестирование — это не просто «хороший тон» в разработке под iOS и macOS, а критически важный этап, обеспечивающий стабильность, безопасность и предсказуемость приложения. В этой статье мы разберём три уровня тестирования на Swift: Unit-тесты, интеграционные тесты и E2E (end-to-end). Вы узнаете, чем они отличаются, когда их применять и как писать эффективные тесты с использованием фреймворков XCTest и XCUITest.
1. Unit-тестирование: проверяем «кирпичики» приложения
Unit-тесты — это самый низкоуровневый и самый быстрый вид тестов. Они проверяют работу отдельных функций, методов или классов (юнитов) в изоляции от внешних зависимостей (сеть, база данных, UI).
Основные принципы Unit-тестов в Swift:
- Изоляция: тестируемый код не должен зависеть от состояния других модулей.
- Повторяемость: тест должен давать одинаковый результат при каждом запуске.
- Быстрота: тысячи unit-тестов должны выполняться за секунды.
Пример Unit-теста с XCTest:
import XCTest
class Calculator {
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
func divide(_ a: Int, _ b: Int) throws -> Int {
guard b != 0 else { throw CalculationError.divisionByZero }
return a / b
}
}
enum CalculationError: Error {
case divisionByZero
}
class CalculatorTests: XCTestCase {
var calculator: Calculator!
override func setUp() {
super.setUp()
calculator = Calculator()
}
override func tearDown() {
calculator = nil
super.tearDown()
}
func testAddition() {
// Given
let a = 5
let b = 3
// When
let result = calculator.add(a, b)
// Then
XCTAssertEqual(result, 8, "5 + 3 должно быть равно 8")
}
func testDivisionByZeroThrowsError() {
// Используем XCTAssertThrowsError для проверки исключений
XCTAssertThrowsError(try calculator.divide(10, 0)) { error in
XCTAssertEqual(error as? CalculationError, .divisionByZero)
}
}
}
Лучшие практики для Unit-тестов:
- Используйте шаблон AAA (Arrange-Act-Assert) — подготовка, действие, проверка.
- Называйте тесты осмысленно:
testMethodName_condition_expectedResult. - Избегайте тестирования private методов — тестируйте публичный интерфейс.
- Для моков и стабов используйте протоколы и dependency injection.
2. Интеграционное тестирование: проверяем взаимодействие компонентов
Интеграционные тесты проверяют, как различные модули приложения работают вместе. В контексте iOS это может быть взаимодействие ViewModel с сетью, Core Data или UserDefaults.
Пример интеграционного теста с моком сетевого слоя:
import XCTest
// Протокол для сетевого сервиса (позволяет легко подменять)
protocol NetworkServiceProtocol {
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void)
}
// Реальный сервис (не тестируем его в интеграционном тесте)
class RealNetworkService: NetworkServiceProtocol {
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
// Реальный HTTP-запрос
}
}
// Mock-сервис для тестирования
class MockNetworkService: NetworkServiceProtocol {
var shouldReturnError = false
var mockUser = User(id: 1, name: "Test User")
func fetchUser(id: Int, completion: @escaping (Result<User, Error>) -> Void) {
if shouldReturnError {
completion(.failure(NSError(domain: "test", code: 500)))
} else {
completion(.success(mockUser))
}
}
}
struct User: Codable, Equatable {
let id: Int
let name: String
}
class UserViewModel {
private let networkService: NetworkServiceProtocol
var userName: String?
var errorMessage: String?
init(networkService: NetworkServiceProtocol) {
self.networkService = networkService
}
func loadUser(id: Int) {
networkService.fetchUser(id: id) { [weak self] result in
switch result {
case .success(let user):
self?.userName = user.name
case .failure(let error):
self?.errorMessage = error.localizedDescription
}
}
}
}
class UserViewModelIntegrationTests: XCTestCase {
func testLoadUserSuccess() {
// Arrange
let mockService = MockNetworkService()
let viewModel = UserViewModel(networkService: mockService)
// Act
viewModel.loadUser(id: 1)
// Assert
XCTAssertEqual(viewModel.userName, "Test User")
XCTAssertNil(viewModel.errorMessage)
}
func testLoadUserFailure() {
// Arrange
let mockService = MockNetworkService()
mockService.shouldReturnError = true
let viewModel = UserViewModel(networkService: mockService)
// Act
viewModel.loadUser(id: 1)
// Assert
XCTAssertNil(viewModel.userName)
XCTAssertNotNil(viewModel.errorMessage)
}
}
Когда писать интеграционные тесты?
- При работе с базами данных (Core Data, Realm).
- При интеграции с REST API или WebSocket.
- При использовании сторонних SDK (Firebase, Alamofire).
- Для проверки корректности DI-контейнеров.
3. E2E-тестирование: проверяем приложение целиком
End-to-end тесты (или UI-тесты) симулируют действия реального пользователя: нажатия, скроллы, ввод текста. В Swift для этого используется фреймворк XCUITest, который запускает приложение на симуляторе или реальном устройстве.
Пример E2E-теста с XCUITest:
import XCTest
class LoginFlowUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testSuccessfulLogin() {
// Находим элементы по accessibility identifier
let usernameField = app.textFields["usernameTextField"]
let passwordField = app.secureTextFields["passwordTextField"]
let loginButton = app.buttons["loginButton"]
let welcomeLabel = app.staticTexts["welcomeLabel"]
// Симулируем ввод данных
usernameField.tap()
usernameField.typeText("testuser")