Тестируемость и внедрение зависимостей

Тестируемость — не про число тестов, а про то, можно ли подменить зависимость, не переписывая класс.

Dependency injection — это просто: объект не создаёт зависимости сам, а получает их снаружи. Всё остальное — детали того, каким путём они приходят.

Вопрос: «Что такое dependency injection и какие способы внедрения вы знаете?»

Что на самом деле проверяют

Отличаете ли вы DI от DI-контейнера. Контейнер — библиотека, DI — принцип, работающий и без сторонних зависимостей. Способов внедрения три.

  • Через инициализатор — предпочтительный: зависимость обязательна, объект после init валиден.
  • Через свойство — когда объект создаёт система (контроллер из storyboard, ячейка). Цена — опциональность и окно неполноты.
  • Через метод — когда зависимость нужна одному вызову: func render(in: CGContext).

Ключевое условие — зависеть от протокола, а не от класса: только так в тесте на место сети встаёт mock.

protocol NetworkService {
    func fetchArticles(page: Int) async throws -> [Article]
}

struct RealNetworkService: NetworkService {
    let client: APIClient

    func fetchArticles(page: Int) async throws -> [Article] {
        try await client.send(.articles(page), as: [Article].self)
    }
}

@MainActor
final class ArticlesViewModel: ObservableObject {
    @Published private(set) var articles: [Article] = []
    @Published private(set) var errorText: String?
    private let network: any NetworkService

    init(network: any NetworkService) { self.network = network }   // внедрение через init

    func load() async {
        do { articles = try await network.fetchArticles(page: 1) }
        catch { errorText = "Не удалось загрузить ленту" }
    }
}

Вопрос: «Как вы тестируете код, который ходит в сеть?»

Что на самом деле проверяют

Что вы не тестируете чужой сервер. Сеть подменяется, проверяется поведение вашего кода: что он делает с успешным ответом, с ошибкой и с пустым списком.

final class MockNetworkService: NetworkService {
    var result: Result<[Article], Error> = .success([])
    private(set) var requestedPages: [Int] = []      // шпион

    func fetchArticles(page: Int) async throws -> [Article] {
        requestedPages.append(page)
        return try result.get()
    }
}

final class ArticlesViewModelTests: XCTestCase {
    @MainActor
    func testLoadFillsArticles() async {
        let mock = MockNetworkService()
        mock.result = .success([Article(id: 1, title: "Swift 6")])
        let sut = ArticlesViewModel(network: mock)

        await sut.load()

        XCTAssertEqual(sut.articles.count, 1)
        XCTAssertEqual(mock.requestedPages, [1])
        XCTAssertNil(sut.errorText)
    }

    @MainActor
    func testLoadShowsErrorText() async {
        let mock = MockNetworkService()
        mock.result = .failure(APIError.invalidResponse)
        let sut = ArticlesViewModel(network: mock)

        await sut.load()

        XCTAssertEqual(sut.errorText, "Не удалось загрузить ленту")
    }

    // старый API с колбэком: ждём через expectation
    func testLegacyCallback() {
        let exp = expectation(description: "загрузка завершилась")
        sut.loadLegacy { XCTAssertNoThrow(try $0.get()); exp.fulfill() }
        wait(for: [exp], timeout: 1.0)
    }
}

Тест на async-код пишется просто: помечаете метод async и ставите await. XCTestExpectation остаётся для API с колбэками и для проверки, что событие произошло асинхронно.

Test double одной строкой каждый

  • dummy — заглушка ради сигнатуры, её методы не вызываются.
  • stub — возвращает заранее заданные ответы, ничего не проверяет.
  • spy — stub, который ещё и записывает, что и сколько раз у него вызвали.
  • mock — знает ожидаемые вызовы и сам заваливает тест при их нарушении.
  • fake — упрощённая рабочая реализация: in-memory репозиторий вместо Core Data.

В Swift граница между stub, spy и mock размыта, и в проектах их все зовут «моками». На собеседовании достаточно показать, что разницу вы понимаете.

Вопрос: «Почему синглтоны мешают тестам?»

Что на самом деле проверяют

Видите ли вы, что Singleton.shared в глубине метода — скрытая зависимость. По сигнатуре её не видно, в init не подменить, и тесты влияют друг на друга через общее состояние: меняется порядок запуска — падают разные тесты.

// Было: зависимости невидимы и неподменяемы
final class CartService {
    func checkout() {
        Analytics.shared.track("checkout")
        NetworkService.shared.post("/orders")
    }
}

// Стало: зависимости видны в сигнатуре
final class CartService {
    private let analytics: any AnalyticsTracker
    private let network: any NetworkService

    init(analytics: any AnalyticsTracker, network: any NetworkService) {
        self.analytics = analytics
        self.network = network
    }
}

Сам shared можно оставить — важно, чтобы его звали в одном месте, на сборке графа. Это Composition Root: единственная точка, где приложение знает конкретные типы.

final class AppContainer {
    lazy var client = APIClient(baseURL: .api, session: .shared, decoder: .app)
    lazy var network: any NetworkService = RealNetworkService(client: client)

    @MainActor
    func makeArticlesViewModel() -> ArticlesViewModel { ArticlesViewModel(network: network) }
}

Что тестировать в первую очередь

Отдача на вложенный час убывает так: бизнес-логика и ViewModel, парсинг и маппинг DTO, форматирование (даты, склонения, валюта), затем интеграция репозиториев. UI-тесты дороги: минуты вместо миллисекунд, хрупкость на анимациях и «мигающие» падения в CI. Компромисс — snapshot-тесты: ловят вёрстку и тёмную тему за секунды, но требуют дисциплины с эталонами и фиксации версии симулятора.

Типичные ошибки кандидатов

  • Путать DI с DI-контейнером и называть библиотеку вместо принципа.
  • Делать все зависимости property injection с ! — объект живёт «наполовину собранным».
  • Тестировать реальную сеть: тест краснеет от чужого деплоя, а не от бага.
  • Мокать всё подряд, включая структуры без побочных эффектов: тест проверяет моки, а не логику.
  • Гнаться за покрытием: 90% на геттерах хуже, чем 40% на бизнес-правилах.

Как ответить кратко

DI — это когда объект получает зависимости снаружи, а не создаёт их сам; способа три — инициализатор, свойство, метод, и по умолчанию я беру инициализатор: зависимости видны в сигнатуре, объект всегда валиден. Завишу от протокола, поэтому в тесте вместо RealNetworkService подставляю mock и проверяю ветки успеха, ошибки и пустого ответа — сеть в юнит-тестах не трогаю. Синглтоны мешают тем, что зависимость скрыта и неподменяема, а состояние общее на все тесты: shared оставляю, но зову только в Composition Root. Стараюсь обойтись инициализаторами, контейнер завожу, когда граф зависимостей действительно большой.

Проверьте себя
1. Какой способ внедрения зависимостей считается предпочтительным и почему?
AЧерез свойство — можно поменять зависимость в любой момент
BЧерез синглтон — меньше кода в инициализаторах
CЧерез инициализатор — зависимость обязательна, объект после init всегда валиден, ничего не может быть nil
DЧерез метод — зависимость не хранится в объекте
2. Чем test double типа spy отличается от stub?
ASpy дополнительно записывает, что и сколько раз у него вызвали
BSpy работает с реальной сетью, а stub — нет
CSpy обязан упасть, если ожидания нарушены
DSpy — это упрощённая рабочая реализация вроде in-memory базы
3. Почему обращение к Singleton.shared внутри метода класса мешает тестам?
AСинглтоны всегда создаются лениво и потому медленные
BЭто скрытая зависимость: её не видно в сигнатуре, её нельзя подменить, а общее состояние связывает тесты между собой
CXCTest не умеет работать со статическими свойствами
DСинглтон нельзя пометить протоколом