Тестируемость и внедрение зависимостей
Тестируемость — не про число тестов, а про то, можно ли подменить зависимость, не переписывая класс.
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. Стараюсь обойтись инициализаторами, контейнер завожу, когда граф зависимостей действительно большой.