← Назад к вопросам

Чему нужно следовать чтобы писать код хорошо покрываемый тестами?

2.3 Middle🔥 171 комментариев
#Архитектура и паттерны#Тестирование и отладка

Комментарии (1)

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Принципы написания кода, хорошо покрываемого тестами

Написание кода, который легко тестировать — это фундаментальный навык, отделяющий зрелого разработчика от новичка. Это не просто "дополнительная задача", а архитектурная дисциплина, влияющая на всю структуру приложения. Вот ключевые принципы, которых я придерживаюсь.

1. Соблюдение принципов SOLID

Это основа. Без них тестирование превращается в борьбу с системами.

  • Принцип единственной ответственности (Single Responsibility): Класс или модуль должен иметь одну причину для изменения. Такой код проще тестировать, потому что у него одна четкая зона ответственности.

    // ПЛОХО: Класс делает всё
    class DataManager {
        func fetchData() { /* сетевой запрос */ }
        func parseData() { /* парсинг JSON */ }
        func saveToDatabase() { /* работа с Core Data */ }
        func updateUI() { /* обновление интерфейса */ }
    }
    // ТЕСТИРОВАТЬ НЕВОЗМОЖНО! Смешаны абстракции, зависимости.
    
    // ХОРОШО: Ответственности разделены
    class NetworkService {
        func fetchData(completion: @escaping (Data?) -> Void) { /* только сеть */ }
    }
    class Parser {
        func parse(data: Data) -> Model? { /* только парсинг */ }
    }
    // Каждый класс можно протестировать изолированно.
    
  • Принцип открытости/закрытости (Open/Closed): Код должен быть открыт для расширения, но закрыт для модификации. Достигается через протоколы (интерфейсы).

    protocol DataFetcher {
        func fetchData(completion: @escaping (Result<Data, Error>) -> Void)
    }
    
    class NetworkFetcher: DataFetcher { ... }
    class MockFetcher: DataFetcher { // Для тестов!
        var predefinedResult: Result<Data, Error> = .success(Data())
        func fetchData(completion: @escaping (Result<Data, Error>) -> Void) {
            completion(predefinedResult)
        }
    }
    
    class DataProcessor {
        let fetcher: DataFetcher // Зависимость от абстракции
        init(fetcher: DataFetcher) {
            self.fetcher = fetcher
        }
        func process() {
            fetcher.fetchData { result in ... }
        }
    }
    // В тесте DataProcessor мы просто инжектим MockFetcher.
    
  • Принцип подстановки Барбары Лисков (Liskov Substitution) и Принцип разделения интерфейса (Interface Segregation): Гарантируют, что зависимости ведут себя предсказуемо и не заставляют клиентов реализовывать ненужные методы. Это критически важно для создания стабильных моков.

  • Принцип инверсии зависимостей (Dependency Inversion): Самый важный для тестирования. Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. На практике это Dependency Injection (DI).

2. Внедрение зависимостей (Dependency Injection)

Не создавай зависимости внутри класса. Получай их извне (через init или свойства).

// ПЛОХО
class UserProfileViewModel {
    private let apiClient = APIClient() // Жесткая зависимость, нельзя подменить
    func loadUser() { apiClient.fetchUser() { ... } }
}

// ХОРОШО
class UserProfileViewModel {
    private let apiClient: APIClientProtocol
    init(apiClient: APIClientProtocol) { // Зависимость инжектируется
        self.apiClient = apiClient
    }
    func loadUser() { apiClient.fetchUser() { ... } }
}

// В тесте:
let mockAPIClient = MockAPIClient()
mockAPIClient.stubbedUser = User.testInstance
let viewModel = UserProfileViewModel(apiClient: mockAPIClient)
// Тестируем viewModel в полной изоляции от сети.

3. Контроль побочных эффектов и чистота функций

Pure function (чистая функция) — идеальный объект для тестирования. Ее результат зависит только от входных аргументов, и у нее нет побочных эффектов (не меняет глобальное состояние, не пишет в БД и т.д.).

// ЧИСТАЯ ФУНКЦИЯ - ИДЕАЛЬНА ДЛЯ ТЕСТОВ
func calculateDiscountedPrice(originalPrice: Double, discountPercent: Double) -> Double {
    guard discountPercent >= 0, discountPercent <= 100 else { return originalPrice }
    return originalPrice * (1 - discountPercent / 100)
}
// Всегда предсказуема. Легко покрывается юнит-тестами.

// ФУНКЦИЯ С ПОБОЧНЫМ ЭФФЕКТОМ - сложнее
func applyDiscountAndSave(to product: Product) {
    product.price *= 0.9 // Побочный эффект (мутация)
    CoreDataStack.shared.save() // Побочный эффект (ввод-вывод)
    NotificationCenter.default.post(...) // Побочный эффект
}
// Для теста нужны моки CoreDataStack и наблюдение за NotificationCenter.

4. Правильное проектирование архитектуры (MVVM, MVP, VIPER, Clean Architecture)

Использование архитектур, разделяющих код на слои с четкими ответственностями, автоматически способствует тестируемости. Например, в MVVM:

  • Model (данные) — тестируются легко.
  • ViewModel (логика представления) — должна быть независима от UIKit. Ее можно целиком покрыть юнит-тестами, так как она получает данные через инжектированные сервисы и преобразует их в состояние для View.
  • View (ViewController) — должен быть максимально "тупым". Его логика сводится к биндингу данных из ViewModel и отправке пользовательских действий обратно. Покрывается UI-тестами (XCUITest).

5. Практические техники

  • Избегай синглтонов в чистом виде: Используй их как инжектируемые зависимости. Вместо прямого вызова NetworkManager.shared, передавай NetworkManagerProtocol экземпляром.
  • Минимизируй использование статических методов и глобальных переменных: Они создают скрытые зависимости, которые сложно подменить в тестах.
  • Пиши функции небольшими и сфокусированными: Одна функция — одна операция. Ее проще протестировать.
  • Используй протоколы для абстракции: Абстрагируй не только сетевые слои, но и UserDefaults, FileManager, NotificationCenter, таймеры (Timer). Это позволяет в тестах подменять их контролируемыми двойниками (mock/stub).
  • Контролируй состояние: Управляй асинхронностью (через DispatchQueue, Operation) так, чтобы ее можно было симулировать или заменить в тестах (например, инжектить специальную TestScheduler).

Итог: Хорошо тестируемый код — это, в первую очередь, хорошо спроектированный, модульный и декомпозированный код. Тестируемость — это не цель, а побочный продукт соблюдения правильных архитектурных принципов. Инвестиция в это на этапе проектирования окупается в десятки раз на этапах рефакторинга, добавления фич и поддержки, так как тесты дают уверенность в изменениях и служат живой документацией.