«Сначала тест — потом код»: почему это работает в Swift?
Вы когда-нибудь переписывали один и тот же модуль в третий раз, потому что «нужно было срочно запустить»? Или ловили баг в продакшене, который проще было предотвратить на этапе написания? Если да — добро пожаловать в мир TDD (Test-Driven Development).
Разработка через тестирование — это не просто модная методология. Это способ мышления, который заставляет вас сформулировать, как должен вести себя код, прежде чем вы его напишете. В Swift, с его строгой типизацией и мощным фреймворком XCTest, TDD превращается в надёжный щит от регрессий и неожиданных падений.
В этой статье мы разберём TDD на реальных примерах, напишем тесты для сетевого слоя и модели данных, а главное — поймём, как внедрить эту практику без боли. Поехали!
Красный-Зелёный-Рефакторинг: магия цикла
Основа TDD — это цикл из трёх шагов:
- Красный (Red) — пишем тест, который не проходит (потому что кода ещё нет).
- Зелёный (Green) — пишем минимальный код, чтобы тест прошёл.
- Рефакторинг (Refactor) — улучшаем код, не меняя его поведения (тесты защищают).
Кажется просто? Попробуем на Swift.
Первый тест: проверяем модель User
Допустим, у нас есть простая модель пользователя. Мы хотим, чтобы она корректно форматировала полное имя. Сначала — тест:
import XCTest
@testable import MyApp
final class UserTests: XCTestCase {
func testFullName_GivenFirstNameAndLastName_ReturnsFormattedString() {
// Arrange
let user = User(firstName: "Анна", lastName: "Каренина")
// Act
let fullName = user.fullName()
// Assert
XCTAssertEqual(fullName, "Анна Каренина", "Полное имя должно состоять из имени и фамилии через пробел")
}
}
Запускаем — тест падает (красный). Компилятор ругается, что User не существует. Отлично, так и задумано.
Теперь пишем минимальный код для прохождения теста:
struct User {
let firstName: String
let lastName: String
func fullName() -> String {
return "\(firstName) \(lastName)"
}
}
Запускаем — зелёный! Можно переходить к рефакторингу. Например, добавить обработку пустых строк или middle name. Но не спешите — тесты подскажут, когда это нужно.
Тестируем асинхронный сетевой слой
Одна из самых частых проблем в iOS-разработке — работа с сетью. TDD здесь особенно полезен, потому что вы явно отделяете логику от сетевых вызовов.
Пишем протокол и mock-объект
Сначала определим, как должен выглядеть наш сервис:
protocol WeatherServiceProtocol {
func fetchWeather(for city: String, completion: @escaping (Result<Weather, Error>) -> Void)
}
Теперь — тест. Мы не хотим зависеть от реального API, поэтому создаём mock:
class MockWeatherService: WeatherServiceProtocol {
var shouldReturnError = false
var mockWeather: Weather?
func fetchWeather(for city: String, completion: @escaping (Result<Weather, Error>) -> Void) {
if shouldReturnError {
completion(.failure(NetworkError.serverError))
} else if let weather = mockWeather {
completion(.success(weather))
}
}
}
final class WeatherViewModelTests: XCTestCase {
func testFetchWeather_Success_UpdatesModel() {
// Arrange
let mockService = MockWeatherService()
mockService.mockWeather = Weather(temperature: 25.0, condition: "sunny")
let viewModel = WeatherViewModel(service: mockService)
let expectation = XCTestExpectation(description: "Ожидание загрузки погоды")
// Act
viewModel.fetchWeather(for: "Москва") {
expectation.fulfill()
}
// Assert
wait(for: [expectation], timeout: 2.0)
XCTAssertEqual(viewModel.currentTemperature, 25.0)
XCTAssertEqual(viewModel.condition, "sunny")
}
}
Запускаем — красный (нет WeatherViewModel). Пишем реализацию:
class WeatherViewModel {
private let service: WeatherServiceProtocol
var currentTemperature: Double?
var condition: String?
init(service: WeatherServiceProtocol) {
self.service = service
}
func fetchWeather(for city: String, completion: @escaping () -> Void) {
service.fetchWeather(for: city) { [weak self] result in
switch result {
case .success(let weather):
self?.currentTemperature = weather.temperature
self?.condition = weather.condition
case .failure:
self?.currentTemperature = nil
self?.condition = nil
}
completion()
}
}
}
Снова зелёный. Обратите внимание: мы не писали реальный сетевой код — только логику ViewModel. Это и есть суть TDD: вы тестируете поведение, а не инфраструктуру.
TDD и Core Data: тестируем persistence
Работа с базой данных — ещё один кандидат на TDD. Часто разработчики боятся тестировать Core Data, но на самом деле это просто.
In-memory store для тестов
Вместо реальной базы используем NSInMemoryStoreType:
class CoreDataStackMock {
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyAppModel")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Ошибка загрузки in-memory store: \(error)")
}
}
return container
}()
var context: NSManagedObjectContext {
return persistentContainer.viewContext
}
}
Теперь пишем тест для сохранения пользователя:
final class UserRepositoryTests: XCTestCase {
var repository: UserRepository!
var mockStack: CoreDataStackMock!
override func setUp() {
super.setUp()
mockStack = CoreDataStackMock()
repository = UserRepository(context: mockStack.context)
}
override func tearDown() {
repository = nil
mockStack = nil
super.tearDown()
}
func testSaveUser_ValidData_UserSaved() {
// Arrange
let user = UserEntity(id: 42, name: "Иван")
// Act
repository.save(user)
// Assert
let fetched = repository.fetchUser(by: 42)
XCTAssertNotNil(fetched)
XCTAssertEqual(fetched?.name, "Иван")
}
}
Красный → пишем реализацию → зелёный. Вуаля — у вас тестируемый слой данных, который можно смело рефакторить.
Паттерны и антипаттерны TDD в Swift
За годы практики я выработал несколько правил, которые помогают не скатиться в «тесты ради тестов».