1. Зачем сервису мок‑репозиторий
Когда вы только начинаете писать программу, всё кажется простым: есть функция, она что-то считает, тест проверяет число на выходе — красота. Но как только вы доходите до приложения «как у взрослых» (например, нашего CLI‑проекта LibraryCLI), логика начинает расползаться по слоям. Сервис решает бизнес‑задачу, а репозиторий читает и пишет данные. И если в тестах сервиса вы случайно начнёте реально читать файл — поздравляю, у вас теперь «тест», который зависит от диска, путей, прав доступа и фазы луны.
Представьте типичный сценарий: сервис «переименовать книгу» должен достать книгу из репозитория, изменить название и сохранить. В реальном приложении репозиторий может быть JSON‑файлом, базой, сетью — чем угодно. И если тест сервиса использует реальный репозиторий, вы перестаёте тестировать сервис как таковой: вы тестируете одновременно сервис, файловую систему и свой уровень терпения.
Чтобы тест был быстрым, детерминированным и понятным, в сервисных тестах мы делаем замену: вместо реального репозитория используем тестовую реализацию, которая ведёт себя предсказуемо. Это и есть мок.
2. Контракт через protocol и внедрение зависимости
Любой мок начинается с одной простой мысли: если зависимость нельзя подменить — её нельзя нормально тестировать. Поэтому мы заставляем сервис зависеть не от JSONFileBookRepository, а от абстракции: BookRepository.
В Swift естественная форма абстракции — protocol. Он описывает «что умеет репозиторий», но не говорит «как именно он это делает». В проде это может быть JSON, в тесте — мок, а сервису всё равно: он работает с контрактом.
Схема зависимостей
flowchart TD
CLI["LibraryCLI (команды)"] --> Service["LibraryService"]
Service -->|depends on protocol| RepoP["BookRepository (protocol)"]
RepoP --> Prod["JSONFileBookRepository (prod)"]
RepoP --> Mock["MockBookRepository (tests)"]
Сервис стоит в центре: он должен быть тестируемым. Для этого его зависимость должна быть выражена как protocol, а внедрение делается через init (dependency injection через конструктор).
3. Мини‑домен для примеров: Book, BookID и ошибки
Перед тем как писать мок, нам нужно минимально «очертить мир», в котором он существует. В примерах ниже мы используем упрощённую доменную модель книги. В вашем проекте она может быть богаче, но принцип не меняется.
Начнём с BookID и Book. Здесь главное — чтобы типы были простые и Equatable, чтобы в тестах можно было удобно сравнивать значения (в тестах мы любим сравнения, потому что они не спорят, в отличие от людей).
import Foundation
struct BookID: Equatable, Hashable {
let rawValue: String
}
struct Book: Equatable {
let id: BookID
let title: String
let author: String
}
Теперь добавим ошибку репозитория. Важно, чтобы она тоже была Equatable: это позволит проверять ошибки в тестах «по делу», а не через сравнение строк.
enum BookRepositoryError: Error, Equatable {
case notFound(id: BookID)
case writeFailed
}
4. Протокол BookRepository: контракт хранилища
Протокол репозитория — это тот самый «разъём», в который можно вставить либо реальную реализацию, либо тестовую. Здесь важно не переусложнить: протокол должен быть достаточно маленьким, чтобы мок был простым, а тесты — читаемыми.
Представим, что для нашего сценария сервису достаточно двух операций: загрузить книгу по ID и сохранить обновлённую книгу.
protocol BookRepository {
func fetchBook(id: BookID) throws -> Book
func saveBook(_ book: Book) throws
}
Обратите внимание на два нюанса, которые очень помогают в тестах.
Во-первых, методы throws. Это даёт нам понятный контракт: репозиторий либо возвращает книгу, либо бросает ошибку. Во-вторых, мы не делаем методы асинхронными и не используем callback’и — это будет отдельная тема дня, а сейчас нам важно держать пример «сухим и предсказуемым».
5. LibraryService: логика переименования книги
Теперь создадим сервис. Он содержит «бизнес‑смысл»: переименовать книгу. Репозиторий — это инфраструктура. На уровне мышления в тестах это звучит так: «я хочу проверить, что сервис правильно переименовывает книгу и правильно реагирует на ошибки репозитория».
Сервис будет зависеть от BookRepository и получать его через init.
final class LibraryService {
private let repo: BookRepository
init(repo: BookRepository) {
self.repo = repo
}
func renameBook(id: BookID, to newTitle: String) throws -> Book {
let book = try repo.fetchBook(id: id)
let updated = Book(id: book.id, title: newTitle, author: book.author)
try repo.saveBook(updated)
return updated
}
}
Заметьте: в сервисе нет ничего про файлы, JSON и пути. Это прекрасно. Чем меньше сервис знает про «железо», тем легче он тестируется.
6. Stub‑мок MockBookRepository на базе Result
В тестах мы хотим управлять поведением репозитория: иногда пусть он возвращает книгу, иногда — бросает .notFound, иногда — симулирует ошибку сохранения.
Самый удобный способ хранить «заранее заданный ответ» — это Result. Он буквально является типом «успех или ошибка», и стандартная библиотека предоставляет Result<Success, Failure: Error>.
А ещё у Result есть метод get(), который либо возвращает значение, либо бросает ошибку — то есть идеально ложится на throws‑методы. Это превращает мок в очень короткий и понятный код.
Сделаем MockBookRepository. В этой лекции мы используем мок как stub: он отвечает заранее подготовленными данными и делает зависимость детерминированной.
final class MockBookRepository: BookRepository {
var fetchResult: Result<Book, BookRepositoryError> =
.failure(.notFound(id: BookID(rawValue: "stub")))
var saveResult: Result<Void, BookRepositoryError> = .success(())
func fetchBook(id: BookID) throws -> Book {
try fetchResult.get()
}
func saveBook(_ book: Book) throws {
_ = try saveResult.get()
}
}
Здесь есть почти «школьная» красота: чтобы поменять поведение мока, тест просто присваивает другое значение fetchResult или saveResult. Никаких файлов. Никаких JSON. Никакой магии. Только детерминизм.
7. Тесты сервиса с мок‑репозиторием
Ниже — три базовых сценария, которые обычно дают максимум пользы на старте: успешное переименование, ошибка при загрузке и ошибка при сохранении.
Успех: репозиторий отдаёт книгу, сохранение проходит
Мы ожидаем: если репозиторий возвращает книгу, сервис возвращает обновлённую книгу с новым названием.
import XCTest
@testable import LibraryCLI
final class LibraryServiceTests: XCTestCase {
func testRenameBook_success_returnsUpdatedBook() throws {
let repo = MockBookRepository()
repo.fetchResult = .success(Book(
id: BookID(rawValue: "b1"),
title: "Old",
author: "Alice"
))
let service = LibraryService(repo: repo)
let updated = try service.renameBook(id: BookID(rawValue: "b1"), to: "New")
XCTAssertEqual(updated.title, "New")
XCTAssertEqual(updated.author, "Alice")
}
}
Обратите внимание на тонкость: мы не пытаемся проверять «сервис точно вызвал save». Это уже проверка взаимодействий (spy‑подход) и относится к следующей лекции. Здесь мы проверяем поведение через результат: сервис вернул обновлённую модель — значит, логика преобразования данных работает.
Ошибка загрузки: репозиторий бросает .notFound
Мы хотим зафиксировать контракт: если репозиторий не нашёл книгу, то сервис должен пробросить эту ошибку наружу (или преобразовать её — но это уже ваше решение дизайна; пока просто пробрасываем).
import XCTest
@testable import LibraryCLI
final class LibraryServiceErrorTests: XCTestCase {
func testRenameBook_whenNotFound_throwsNotFound() {
let repo = MockBookRepository()
let missingID = BookID(rawValue: "404")
repo.fetchResult = .failure(.notFound(id: missingID))
let service = LibraryService(repo: repo)
XCTAssertThrowsError(try service.renameBook(id: missingID, to: "New")) { error in
XCTAssertEqual(error as? BookRepositoryError, .notFound(id: missingID))
}
}
}
Здесь мы используем технику из предыдущих лекций: внутри XCTAssertThrowsError ошибка приходит как Error, поэтому мы приводим её к конкретному типу через as? и сравниваем как Equatable.
Ошибка сохранения: репозиторий «падает» на save
Ещё один полезный сценарий: книга нашлась, но сохранить её не удалось (например, диск недоступен). Сервис в такой ситуации тоже должен бросить ошибку (опять же: либо как есть, либо преобразованную — сейчас не усложняем).
import XCTest
@testable import LibraryCLI
final class LibraryServiceSaveErrorTests: XCTestCase {
func testRenameBook_whenSaveFails_throwsWriteFailed() {
let repo = MockBookRepository()
repo.fetchResult = .success(Book(
id: BookID(rawValue: "b2"),
title: "Old",
author: "Bob"
))
repo.saveResult = .failure(.writeFailed)
let service = LibraryService(repo: repo)
XCTAssertThrowsError(try service.renameBook(id: BookID(rawValue: "b2"), to: "New")) { error in
XCTAssertEqual(error as? BookRepositoryError, .writeFailed)
}
}
}
Этот тест ценен тем, что он проверяет: сервис не «глотает» ошибки и не делает вид, что всё хорошо. В CLI‑приложении это особенно важно: если мы молча потеряли ошибку сохранения, пользователь потом будет уверен, что книга переименована, а файл на диске останется старым.
8. Полезные нюансы: Result, stub и границы сложности
Почему Result — удобный «двигатель» моков
Когда новичок впервые видит Result, у него часто возникает реакция: «зачем мне ещё один тип, если есть throws?». В реальном коде вы действительно чаще пишете throws, но в тестовых двойниках Result внезапно становится очень удобным контейнером для «подготовленного ответа». Он хранится как обычное значение, его легко переопределять, а get() превращает его обратно в throws‑поведение.
Плюс Result как концепция вообще был введён в стандартную библиотеку именно как инструмент представления «успех/ошибка» значением и упрощения композиции в некоторых сценариях. В тестах это ощущается особенно приятно: мок становится похож на маленький «переключатель судьбы», которым вы управляете прямо из теста.
Где мок перестаёт помогать
Есть один классический способ сделать тесты бесполезными: написать мок с такой логикой, что он сам начинает повторять поведение реального репозитория или сервиса. Тогда у вас получается ситуация «мы тестируем сервис через другой сервис». Ошибка в логике может быть в обоих местах, и тест радостно подтвердит неправильное поведение как «правильное» — потому что мок тоже неправильно считает.
Хороший мок‑stub обычно максимально тупой (это комплимент). Он должен быть механическим: «верни это», «брось то». Если вы ловите себя на том, что в мок добавился if, потом ещё один if, потом «ну давайте ещё массив внутри хранить и искать по id» — остановитесь, вы уже пишете маленький репозиторий. Иногда это уместно (это будет ближе к fake), но в рамках сервисных unit‑тестов чаще всего вам достаточно stubs.
Реальный репозиторий и мок в тестах
Чтобы не путаться в терминологии, полезно держать в голове простое сравнение. Здесь мы говорим именно про текущую лекцию (stub‑мок), без проверки взаимодействий.
| Реализация | Зачем существует | От чего зависит | Что делает в тесте |
|---|---|---|---|
| JSONFileBookRepository (prod) | Хранит данные реально | Файлы, пути, права, формат | В unit‑тестах сервиса обычно не нужен |
| MockBookRepository (stub) | Делает тесты детерминированными | Только код теста | Возвращает заранее заданный Result |
9. Типичные ошибки при написании MockRepository
Ошибка №1: сервис сам создаёт репозиторий внутри себя.
Если внутри LibraryService написать что-то вроде let repo = JSONFileBookRepository(...), то вы лишаете тест возможности подмены. В итоге сервис нельзя изолированно протестировать: он всегда тащит за собой инфраструктуру. Лечится просто: зависимость передаём через init(repo:).
Ошибка №2: мок становится «умнее», чем нужно, и превращается в альтернативную реализацию репозитория.
Когда мок начинает хранить коллекции, обновлять их, валидировать данные и «как бы работать по-настоящему», он перестаёт быть простым инструментом теста. Тест начинает зависеть от логики мока, и вы теряете уверенность, что проверяете именно сервис. Для stub‑мока держите правило: минимум поведения, максимум предсказуемости.
Ошибка №3: мок кидает ошибки не того типа, что в контракте.
Иногда хочется в моке бросить какой-нибудь NSError(domain:..., code:...), потому что «да какая разница». Разница есть: сервисный код и тесты должны работать с теми же типами ошибок, что и реальный контракт. Если BookRepository обещает BookRepositoryError, то мок должен использовать его же — иначе тесты начинают проверять не тот сценарий.
Ошибка №4: тест проверяет только «не упало», но не проверяет результат.
Сценарий «не должно бросать» важен, но обычно недостаточен. Если renameBook не упал, но вернул книгу со старым названием — формально “no throw” пройдёт, а смысл теста потерян. Разделяйте ожидания: отдельно фиксируйте, что не бросает, и отдельно — что значение корректное.
Ошибка №5: попытка проверить взаимодействия через stub‑мок и получить хрупкий тест.
Как только вы начинаете в мок добавлять логи вызовов и проверять порядок методов, вы уже переходите в область spy‑моков и тестов взаимодействий. Это полезно, но если смешать всё в одну кучу, тесты становятся ломкими: любое мелкое изменение внутренней реализации сервиса начинает «валить» тесты, хотя поведение для пользователя не изменилось. В этой лекции держим фокус на stubs: вход → заранее заданный ответ → проверка результата/ошибки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ