TDD в Swift: как писать код, который не ломается

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

«Сначала тест — потом код»: почему это работает в 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

За годы практики я выработал несколько правил, которые помогают не скатиться в «тесты ради тестов».

Что делать (

Блоги

Рекомендаций по книгам