JavaRush /Курсы /Swift SELF /Моки через protocol...

Моки через protocol: MockBookRepository

Swift SELF
53 уровень , 0 лекция
Открыта

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: вход → заранее заданный ответ → проверка результата/ошибки.

1
Задача
Swift SELF, 53 уровень, 0 лекция
Недоступна
Приветствие бота
Приветствие бота
1
Задача
Swift SELF, 53 уровень, 0 лекция
Недоступна
Переименование тома
Переименование тома
1
Задача
Swift SELF, 53 уровень, 0 лекция
Недоступна
Смета заказа
Смета заказа
1
Задача
Swift SELF, 53 уровень, 0 лекция
Недоступна
Рейтинг авторов
Рейтинг авторов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ