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 -->|залежить від protocol| RepoP["BookRepository (protocol)"]
    RepoP --> Prod["JSONFileBookRepository (прод)"]
    RepoP --> Mock["MockBookRepository (тести)"]

Сервіс стоїть у центрі, бо саме його ми маємо тестувати. Для цього його залежність має бути виражена як 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: вхід → заздалегідь задана відповідь → перевірка результату/помилки.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ