1. Коли потрібен interaction-тест
Коли ви тільки починаєте писати тести, дуже хочеться жити в затишному світі: викликали функцію, отримали значення, порівняли його з очікуваним. Цей світ чудовий, як перший print("Привіт, Swift"): усе просте й передбачуване.
Але сервісний код, особливо в CLI-застосунку на кшталт нашого LibraryCLI, часто робить речі, де результат — це не одне значення, а послідовність дій: завантажити дані, перевірити їх, зберегти, видалити, оновити індекс, записати лог… і все це може відбуватися навіть тоді, коли назовні сервіс повертає Void.
Уявіть, що метод сервісу називається renameBook(id:to:). Він може нічого не повертати — успішно перейменували, і добре. Але його ключова поведінка не в тому, що повернули, а в тому, що зробили: спочатку дістали книгу з репозиторію, потім зберегли оновлену. Якщо ви перевіряєте лише те, що метод «не впав», ви пропустите ситуацію, коли ми взагалі забули викликати save, і перейменування працюватиме тільки у фантазії розробника — тобто в голові, де все завжди працює.
Саме тут і з’являється тест взаємодії: він фіксує контракт рівня «сервіс зобов’язаний викликати репозиторій саме так». Це звучить бюрократично, але на практиці економить години налагодження й трохи нервів.
Міні-контекст: сервіс і репозиторій
Щоб тестувати взаємодії, нам потрібен об’єкт, з яким можна взаємодіяти. Тож візьмімо невеликий фрагмент доменної логіки нашого навчального LibraryCLI: у нас є книга (Book), є репозиторій (BookRepository), і є сервіс (LibraryService), який реалізує бізнес-операції.
Одразу домовімося про хороший стиль: сервіс не створює репозиторій усередині себе, а отримує його через init. Це саме те, що ми вже використовували для stub-моків: впровадження залежностей робить тестування можливим без зайвого чаклування.
Мінімальні моделі — у дусі «5–10 рядків, без монстрів»:
public struct Book: Equatable {
public let id: Int
public var title: String
}
public protocol BookRepository {
func fetchBook(id: Int) throws -> Book
func saveBook(_ book: Book) throws
}
Тепер сам сервіс. Він навмисно простий: завантажуємо книгу, змінюємо заголовок, зберігаємо.
public final class LibraryService {
private let repo: BookRepository
public init(repo: BookRepository) {
self.repo = repo
}
public func renameBook(id: Int, to newTitle: String) throws {
var book = try repo.fetchBook(id: id)
book.title = newTitle
try repo.saveBook(book)
}
}
Зверніть увагу на важливу річ: тут немає нічого «тестового». Це звичайний робочий код. А тестова магія житиме в тестах — там їй і місце.
2. Spy-моки на практиці
Stub, Spy і гібрид
Коли ми робили stub-моки, наша тестова реалізація протоколу зазвичай відповідала на запитання «що повернути?». Але для перевірки взаємодій нам потрібна інша навичка: «що записати?».
Stub (заглушка) зручний, коли сервісу потрібно отримати дані й піти далі, а ми хочемо повністю контролювати вхід. Spy (шпигун) зручний, коли ми хочемо побачити, що саме робив сервіс: які методи викликав, з якими аргументами, скільки разів і в якому порядку.
На практиці часто виходить гібрид: один і той самий мок і повертає заздалегідь задані дані, і веде журнал викликів. Це нормально. Просто важливо тримати його достатньо маленьким, щоб він не перетворився на «другу реалізацію репозиторію, тільки гіршу».
Щоб було простіше візуально, ось компактна таблиця:
| Тестовий подвійник | Головне запитання | Типовий артефакт |
|---|---|---|
| Заглушка | «Що повернути сервісу?» | |
| Шпигун | «Що викликав сервіс?» | |
| Гібрид | «І повернути, і записати» | |
Spy-репозиторій: лог подій як enum
Коли тест намагається перевірити взаємодії, у нього є типова проблема: він знає лише «метод викликали», але не знає, у якій формі зберігати історію. Найчитабельніший варіант — завести enum Event, де кожен кейс відповідає виклику методу, а пов’язані значення зберігають аргументи.
Це особливо зручно, тому що Event можна зробити Equatable, а отже — порівняти масив подій цілком через XCTAssertEqual. Тобто замість «перевіримо 5 прапорців і 3 лічильники» ми отримуємо перевірку, яка читається як сценарій.
Скелет spy-репозиторію:
final class SpyBookRepository: BookRepository {
enum Event: Equatable {
case fetch(id: Int)
case save(Book)
}
private(set) var events: [Event] = []
}
Чому private(set)? Тому що тест має читати історію викликів, але не має випадково її зіпсувати — наприклад, дописати туди подію й «виправити тест руками», навіть не помітивши.
Тепер додамо можливість керувати тим, що поверне fetchBook. Для простоти використаємо Result:
final class SpyBookRepository: BookRepository {
enum Event: Equatable {
case fetch(id: Int)
case save(Book)
}
private(set) var events: [Event] = []
var fetchResult: Result<Book, Error> = .failure(NSError())
func fetchBook(id: Int) throws -> Book {
events.append(.fetch(id: id))
return try fetchResult.get()
}
func saveBook(_ book: Book) throws {
events.append(.save(book))
}
}
Так, тут є NSError(), і це не «ідеально красиво». Але це нормальний навчальний компроміс: нам неважливо, яка саме помилка, коли ми перевіряємо успішний сценарій; нам важливо, що fetchResult можна замінити на .success(...) у тесті.
Тест: renameBook викликає fetch, потім save
Тепер пишемо сам тест. У реальному проєкті це буде файл у тест-таргеті, наприклад DomainTests/LibraryServiceInteractionTests.swift. І не забуваємо про import XCTest.
Якщо у вас окремий модуль Domain, тоді буде @testable import Domain. Домовленості SwiftPM про те, де лежать тести і як вони іменуються, існують, щоб тестова інфраструктура не ворожила на кавовій гущі.
Спочатку етап підготовки: готуємо spy-репозиторій, програмуємо відповідь fetch, створюємо сервіс.
import XCTest
final class LibraryServiceInteractionTests: XCTestCase {
func testRenameBook_callsFetchThenSave() throws {
let repo = SpyBookRepository()
repo.fetchResult = .success(Book(id: 10, title: "Старий"))
let service = LibraryService(repo: repo)
try service.renameBook(id: 10, to: "Новий")
XCTAssertEqual(repo.events, [
.fetch(id: 10),
.save(Book(id: 10, title: "Новий"))
])
}
}
Цей тест дуже «розмовний»: навіть людина, яка не знає проєкту, бачить правило — перейменування має спочатку завантажити книгу, а потім зберегти оновлену.
Перевірка аргументів без рядкової магії
Коли тестують взаємодії, новачки іноді починають перевіряти аргументи так: «ну я зроблю String(describing:) і порівняю рядки». Це спокусливо, бо швидко. Але це крихко: формат опису змінюється, пробіли змінюються, ви додасте поле в модель — і тести почнуть падати там, де бізнес-логіка не зламалася.
Тож краще спиратися на нормальні інструменти мови: Equatable на моделях і Equatable на подіях. Ми вже зробили Book: Equatable, а отже .save(Book(...)) порівнюється чесно, по полях.
Якщо раптом модель не можна зробити Equatable (наприклад, там усередині щось непорівнюване), то найчастіше правильний крок — порівняти лише потрібні поля всередині switch, а не «весь об’єкт цілком». Це зберігає сенс тесту: ми перевіряємо контракт, а не серіалізацію об’єкта в рядок.
Приклад ручної перевірки важливого — усе ще компактний:
let saveEvents = repo.events.compactMap { event -> Book? in
if case let .save(book) = event { return book }
return nil
}
XCTAssertEqual(saveEvents.first?.title, "Новий")
Тут ми не перевіряємо «все підряд», але чітко фіксуємо ключову частину контракту: новий заголовок має дійти до репозиторію.
Негативний сценарій: під час помилки fetch не можна викликати save
Перевірка «сервіс викликає репозиторій» особливо важлива не лише в успішному сценарії, а й у випадках, коли щось пішло не так. Бо багато багів якраз із серії: отримали помилку, але чомусь продовжили виконувати наступні кроки — і тепер ще й дані зіпсували.
Давайте додамо просте правило: якщо fetchBook завершився помилкою, то saveBook не має бути викликаний узагалі. Це виглядає очевидно, але такі очевидності дивовижно часто ламаються під час рефакторингу.
Тест буде приблизно таким: запрограмуємо fetchResult на .failure, викличемо сервіс і перевіримо дві речі. По-перше, сервіс справді кинув помилку. По-друге, у подіях є лише fetch, без save.
func testRenameBook_whenFetchFails_doesNotCallSave() {
let repo = SpyBookRepository()
repo.fetchResult = .failure(NSError(domain: "test", code: 1))
let service = LibraryService(repo: repo)
XCTAssertThrowsError(try service.renameBook(id: 10, to: "Новий"))
XCTAssertEqual(repo.events, [.fetch(id: 10)])
}
Зверніть увагу на стиль: ми не робимо з цього «роман на 40 сторінок». Мінімально й по суті. Помилка була — збереження не було.
Порядок викликів: коли він частина контракту
Порядок викликів — річ із характером. Іноді він справді є частиною контракту, а іноді ви просто випадково зробили тест крихким.
У нашому прикладі порядок важливий майже «фізично»: не можна зберегти оновлену книгу, не завантаживши початкову (якщо тільки ваша бізнес-логіка не допускає «сліпе збереження» — але тоді й сервіс був би інший). Тому порівняння масиву подій тут виправдане.
Але буває, що порядок неважливий: наприклад, сервіс спочатку логує, потім валідує, потім ще раз логує… і ви не хочете, щоб тест ламався через те, що логування переїхало на інший рядок. У таких випадках краще перевіряти сам факт потрібної події без фіксації повного сценарію.
Наприклад, перевірити, що був хоча б один save:
let didSave = repo.events.contains { event in
if case .save = event { return true }
return false
}
XCTAssertTrue(didSave)
Це менш «красиво», ніж порівняння масиву, але іноді набагато стійкіше. І, як не дивно, стійкість тестів — це теж частина якості: тести, які падають від кожного чхання, перестають бути помічниками й перетворюються на шум.
3. Як тримати interaction-тести корисними
Міні-схема: що саме ми тестуємо
Іноді корисно буквально намалювати, що відбувається: сервіс викликає репозиторій, а spy-репозиторій записує події, і тест потім порівнює їх з очікуваними.
flowchart LR
T["XCTest: тест renameBook…"] --> S["LibraryService.renameBook"]
S --> R["BookRepository (шпигун)"]
R --> L["events.append(...)"]
T -->|XCTAssertEqual| L
У цій схемі немає нічого «магічного». Spy — це не фреймворк і не чорна скринька, а звичайний клас із масивом.
Не шпигувати заради шпигунства
Є поширена пастка: дізнавшись про spy-моки, початківець у тестуванні починає перевіряти взагалі все. «Сервіс викликав репозиторій рівно 3 рази, потім зробив вдих, потім видих…». Це призводить до того, що тести стають прив’язані до внутрішньої реалізації сильніше, ніж до контракту. Ви змінюєте код, не змінюючи поведінку, а тести падають. За тиждень ви починаєте не довіряти тестам, а за два — вимикаєте їх у CI.
Щоб цього не відбувалося, тримайте в голові просте правило: interaction-перевірки потрібні там, де взаємодія — і є поведінка. Якщо поведінка чудово виражається через значення, яке повертається, — краще тестувати саме його. Якщо поведінка виражається через «команду залежностям» (save/delete/send/request) — тоді spy виправданий.
У нашому навчальному LibraryCLI репозиторій — це межа: саме через нього дані потрапляють у сховище. Тому перевірка «сервіс правильно викликає репозиторій» — логічна. Але перевірка «сервіс створив дві локальні змінні замість трьох» — ні, це вже внутрішня кухня.
4. Типові помилки
Помилка №1: spy зберігає десяток прапорців і лічильників замість нормального лога подій.
Таке часто народжується від бажання «швидше»: var fetchCalled = false, var saveCalled = false, var savedBook: Book?… За кілька ітерацій з’являються fetchCallCount, saveCallCount, lastSavedBook, allSavedBooks, і мок перетворюється на склад. Лог подій через enum Event дисциплінує: у вас одна структура даних, одне джерело правди й простий XCTAssertEqual замість зоопарку перевірок.
Помилка №2: тест перевіряє надто багато внутрішніх кроків, які не є контрактом.
Якщо ви порівнюєте весь events-масив, ви фіксуєте порядок і точний набір викликів. Це потрібно лише тоді, коли порядок справді важливий. Якщо порядок не важливий, краще перевіряти наявність потрібної події. Інакше будь-яка «косметична» зміна коду ламає тести, хоча користувач узагалі не помітив би різниці.
Помилка №3: spy публічно віддає масив подій на запис.
Якщо events зробити просто var events: [Event], тест може випадково або «випадково спеціально» модифікувати його й отримати хибнопозитивний результат. private(set) — маленька деталь, але вона економить час, коли проєкт стає більшим і ви вже не пам’ятаєте, що писали тиждень тому.
Помилка №4: interaction-тести замінюють перевірку результату, хоча результат можна перевірити напряму.
Якщо функція повертає обчислене значення, і його легко перевірити, то перевіряти «які методи викликалися всередині» найчастіше зайве. У такому тесті ви фіксуєте реалізацію, а не поведінку. Spy — це не «правильніше», це просто інший інструмент.
Помилка №5: мок починає містити бізнес-логіку та умовні гілки, як справжній.
Spy-репозиторій не має ставати «другим репозиторієм». Його завдання — повернути заздалегідь задане й записати виклики. Чим більше в ньому логіки — гілок, перевірок, обробки — тим більший ризик, що ви протестуєте мок, а не сервіс. І це саме той випадок, коли тести зелені, а застосунок червоний.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ