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

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

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

1. Когда нужен interaction‑тест

Когда вы только начинаете писать тесты, очень хочется жить в уютном мире «вызвал функцию — получил значение — сравнил с ожидаемым». Этот мир прекрасен, как первый print("Hello, Swift"): всё простое и предсказуемое.

Но сервисный код (особенно в CLI‑приложении вроде нашего LibraryCLI) часто делает вещи, у которых результат — это не одно значение, а последовательность действий: загрузить данные, провалидировать, сохранить, удалить, обновить индекс, записать лог… и всё это может происходить даже тогда, когда наружу сервис возвращает Void.

Представьте, что метод сервиса называется renameBook(id:to:). Он может ничего не возвращать (успешно переименовали — молодцы). Но его ключевое поведение не в «что вернули», а в «что сделали»: сначала вытащили книгу из репозитория, потом сохранили обновлённую. Если вы проверяете только то, что метод «не упал», вы пропустите ситуацию «мы вообще забыли вызвать save», и переименование будет работать только в фантазии разработчика (то есть в голове, где всё всегда работает).

Вот тут и появляется тест взаимодействий: он фиксирует контракт уровня «сервис обязан вызвать репозиторий так-то». И это звучит бюрократично, но на практике экономит часы отладки и немного нервов.

Мини‑контекст: сервис и репозиторий

Чтобы тестировать взаимодействия, нам нужно, чтобы было с чем взаимодействовать. Поэтому давайте возьмём небольшой фрагмент доменной логики нашего учебного LibraryCLI: у нас есть книга (Book), есть репозиторий (BookRepository), и есть сервис (LibraryService), который реализует бизнес‑операции.

Сразу договоримся о хорошем стиле: сервис не создаёт репозиторий внутри себя, а получает его через init. Это ровно то, что мы уже использовали для stub‑моков: dependency injection делает тестирование возможным без шаманства.

Минимальные модели (в духе «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)
    }
}

Обратите внимание на важную вещь: здесь нет ничего «тестового». Это нормальный production‑код. А тестовая магия будет жить в тестах — там ей и место.

2. Spy‑моки на практике

Stub, Spy и гибрид

Когда мы делали stub‑моки, наша тестовая реализация протокола обычно отвечала на вопрос «что вернуть?». Но для проверки взаимодействий нам нужен другой навык: «что записать?».

Stub (заглушка) удобен, когда сервису нужно «получить данные» и пойти дальше, а мы хотим полностью контролировать вход. Spy (шпион) удобен, когда мы хотим увидеть, что сервис делал: какие методы вызвал, с какими аргументами, сколько раз и в каком порядке.

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

Чтобы было проще глазами, вот компактная табличка:

Test double Главный вопрос Типичный артефакт
Stub «Что вернуть сервису?»
var result: Result<…>
Spy «Что сервис вызвал?»
events: [Event]
Hybrid «И вернуть, и записать»
result + events

Spy‑репозиторий: лог событий как enum

Когда тест пытается проверить взаимодействия, у него есть типичная проблема: он знает только «вызвали метод», но не знает, в какой форме хранить историю. Самый читаемый вариант — завести enum Event, где каждый кейс соответствует вызову метода, а associated values хранят аргументы.

Это особенно удобно, потому что 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(), и это не «идеально красиво». Но это нормальный учебный компромисс: нам неважно, какая именно ошибка, когда мы проверяем happy path; нам важно, что fetchResult можно заменить на .success(...) в тесте.

Тест: renameBook вызывает fetch, затем save

Теперь пишем сам тест. В реальном проекте это будет файл в тест‑таргете, например DomainTests/LibraryServiceInteractionTests.swift. И не забываем про import XCTest.

Если у вас отдельный модуль Domain, тогда будет @testable import Domain. Соглашения SwiftPM о том, где лежат тесты и как они именуются, существуют, чтобы тест‑инфраструктура не гадала на кофейной гуще.

Сначала arrange: готовим spy‑репозиторий, программируем ответ fetch, создаём сервис.

import XCTest

final class LibraryServiceInteractionTests: XCTestCase {
    func testRenameBook_callsFetchThenSave() throws {
        let repo = SpyBookRepository()
        repo.fetchResult = .success(Book(id: 10, title: "Old"))

        let service = LibraryService(repo: repo)
        try service.renameBook(id: 10, to: "New")

        XCTAssertEqual(repo.events, [
            .fetch(id: 10),
            .save(Book(id: 10, title: "New"))
        ])
    }
}

Этот тест очень «разговорный»: даже человек, который не знает проект, видит правило — переименование должно сначала загрузить книгу, потом сохранить обновлённую.

Проверка аргументов без строковой магии

Когда тестируют взаимодействия, новички иногда начинают проверять аргументы так: «ну я сделаю 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, "New")

Здесь мы не проверяем «всё подряд», но чётко фиксируем ключевую часть контракта: новый заголовок должен дойти до репозитория.

Негативный сценарий: при ошибке fetch нельзя вызывать save

Проверка «сервис вызывает репозиторий» особенно важна не только в happy path, но и в сценариях, где что-то пошло не так. Потому что многие баги как раз из серии: «получили ошибку, но почему-то продолжили выполнять следующие шаги, и теперь ещё и данные испортили».

Давайте добавим простое правило: если 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: "New"))
    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: testRenameBook..."] --> S["LibraryService.renameBook"]
    S --> R["BookRepository (Spy)"]
    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 лекция
Недоступна
Чистый заголовок
Чистый заголовок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ