JavaRush /Курси /Swift SELF /Перевірка взаємодій: сервіс викликає репозиторій

Перевірка взаємодій: сервіс викликає репозиторій

Swift SELF
Рівень 53 , Лекція 1
Відкрита

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 (шпигун) зручний, коли ми хочемо побачити, що саме робив сервіс: які методи викликав, з якими аргументами, скільки разів і в якому порядку.

На практиці часто виходить гібрид: один і той самий мок і повертає заздалегідь задані дані, і веде журнал викликів. Це нормально. Просто важливо тримати його достатньо маленьким, щоб він не перетворився на «другу реалізацію репозиторію, тільки гіршу».

Щоб було простіше візуально, ось компактна таблиця:

Тестовий подвійник Головне запитання Типовий артефакт
Заглушка «Що повернути сервісу?»
var result: Result<…>
Шпигун «Що викликав сервіс?»
events: [Event]
Гібрид «І повернути, і записати»
result + events

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-репозиторій не має ставати «другим репозиторієм». Його завдання — повернути заздалегідь задане й записати виклики. Чим більше в ньому логіки — гілок, перевірок, обробки — тим більший ризик, що ви протестуєте мок, а не сервіс. І це саме той випадок, коли тести зелені, а застосунок червоний.

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