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: вхід → заздалегідь задана відповідь → перевірка результату/помилки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ