Проверка отмены и времени жизни подписок Combine на облачном Mac

Проверка отмены и времени жизни подписок Combine на облачном Mac

Проверка отмены и времени жизни подписок Combine на облачном Mac

Если результаты поиска продолжают записываться в состояние после ухода с экрана или число запросов растёт при каждом повторном открытии страницы, причина часто не в медленной сети, а в слишком долгой жизни подписок Combine. При запуске регрессионных тестов на облачном Mac сначала замените реальную сеть управляемым источником событий: явно определите, когда создаётся подписка, когда она отменяется и какой объект должен освободиться. Затем подключайте настоящий сервис. Так сбои можно воспроизводить стабильно, не дожидаясь ответа удалённого сервера.

Сначала разделите гарантии отмены

При отмене нужно различать как минимум три результата: нижележащий подписчик больше не получает значения, вышележащий источник получает сигнал отмены, а базовая задача останавливается. Первые два можно проверить тестами цепочки издателей; третий требует проверки реализации конкретной задачи — состояние подписки само по себе ничего не доказывает.

Задайте для экрана четыре критерия приёмки: после отмены значение не меняется; повторная отмена не вызывает повторную очистку; модель экрана освобождается; старый запрос не может перезаписать результат нового. Для штатного завершения нужен отдельный тест: уведомление о завершении не равнозначно уведомлению об отмене.

cancel — это не откат. Уже записанное состояние, сохранённые на диск файлы и операции, уже переданные на выполнение серверу, автоматически не отменяются.

Сначала зафиксируйте используемый набор инструментов Xcode и добавьте тесты в существующий таргет модульных тестов XCTest. Примеры ниже зависят только от Combine и XCTest: запускать интерфейс в симуляторе и предоставлять доступ к сети не требуется.

Проверьте распространение отмены на синхронном источнике событий

Добавьте следующий код в тестовый таргет. Счётчик здесь нужен только для теста; в рабочем коде не следует использовать глобальные счётчики как признак состояния отмены. Тест отправляет одно значение, дважды отменяет подписку, затем отправляет другое значение. Три проверки последовательно подтверждают получение значения, распространение отмены и отсутствие доставки после отмены.

import Combine
import XCTest

final class SubscriptionLifetimeTests: XCTestCase {
    func testCancelStopsDeliveryAndPropagatesOnce() {
        let source = PassthroughSubject<Int, Never>()
        var received: [Int] = []
        var cancellations = 0

        let token = source
            .handleEvents(receiveCancel: {
                cancellations += 1
            })
            .sink { value in
                received.append(value)
            }

        source.send(1)
        XCTAssertEqual(received, [1])

        token.cancel()
        token.cancel()
        source.send(2)

        XCTAssertEqual(cancellations, 1)
        XCTAssertEqual(received, [1])
    }
}

В этой цепочке нет переключения планировщика, поэтому PassthroughSubject отправляет значения синхронно, а проверки могут идти сразу после соответствующих действий. Не добавляйте sleep, чтобы «дать системе время»: это лишь скроет неопределённость в порядке событий.

Этот тест проверяет только цепочку из примера. При подключении собственного сервиса передайте источник событий в бизнес-модель через внедрение зависимостей и выполните те же проверки через открытое состояние модели. Тестировать только цепочку, не связанную с продуктом, недостаточно.

Проверяйте связи владения, а не только cancel

Типичный цикл сильных ссылок возникает, когда модель хранит AnyCancellable, а замыкание sink удерживает модель сильной ссылкой. Если рассчитывать, что подписка освободится при уничтожении модели, получится взаимное ожидание. В следующем примере слабый захват разрывает цикл, а слабая ссылка позволяет проверить, что объект действительно освободился.

private final class ScreenModel {
    var value = 0
    private var token: AnyCancellable?

    init(source: PassthroughSubject<Int, Never>) {
        token = source.sink { [weak self] value in
            self?.value = value
        }
    }
}

extension SubscriptionLifetimeTests {
    func testModelReleaseCancelsSubscription() {
        let source = PassthroughSubject<Int, Never>()
        var cancellations = 0
        let publisher = source.handleEvents(receiveCancel: {
            cancellations += 1
        })
        var token: AnyCancellable?
        weak var weakModel: ScreenModel?

        do {
            let model = ScreenModel(source: source)
            weakModel = model
            token = publisher.sink { [weak model] value in
                model?.value = value
            }
        }

        XCTAssertNil(weakModel)
        token?.cancel()
        XCTAssertEqual(cancellations, 1)
    }
}

Во втором тесте освобождение модели и отмена внешней подписки наблюдаются отдельно, а не считаются одним действием. Внутренняя подписка модели отменяется при освобождении модели; внешний token всё ещё удерживается тестом, и его нужно отменить явно. При переносе в реальный проект добавьте точку наблюдения за отменой внутренней подписки и проверяйте обе подписки по отдельности.

Слабый захват — тоже не универсальное решение. Если задача должна продолжаться после ухода с экрана, владеть ею должен сервис с более длительным временем жизни, а экрану следует лишь подписываться на ход выполнения. Так освобождение экрана не остановит задачу по ошибке.

Добавьте проверку идентификатора при асинхронной записи результата

После отмены поиска A и запуска поиска B работа, уже поставленная в другую очередь выполнения, всё ещё может попытаться записать результат. Сам факт вызова отмены не гарантирует безопасность состояния.

Назначайте каждому запросу возрастающий идентификатор и сверяйте его при записи результата. Изменение идентификатора и запись состояния должны выполняться в одном actor или последовательном контексте выполнения. Зафиксируйте порядок теста: запустить A, запустить B, вернуть ответ B и лишь затем вернуть ответ A. Итоговое состояние должно по-прежнему соответствовать B.

Добавьте отдельный сценарий ухода с экрана: запустите запрос, покиньте экран, вызовите запоздалый ответ и убедитесь, что видимое состояние не обновилось. Если бизнес-логика допускает продолжение работы в фоне, отдельно проверьте обновление состояния сервиса и прекращение обратных вызовов экрана.

Если используется receive(on:), сначала проверьте синхронную часть, а затем границу переключения планировщика. Продвигайте очередь управляемым планировщиком. Если такой инфраструктуры нет, дожидайтесь явного сигнала о завершении выполнения: нельзя объявлять, что запоздалых событий нет, лишь потому, что прошло некоторое время.

Превратите тесты в регрессионные проверки с понятной диагностикой

При каждом падении теста фиксируйте идентификатор запроса, порядок событий, число отмен и итоговое состояние, но не записывайте токены запросов или реальные пользовательские данные. Меняйте только одно условие за раз: сначала проверьте одиночную подписку, затем охватите быстрое повторное создание подписки, штатное завершение, завершение с ошибкой и освобождение экрана.

При подключении реальной сетевой задачи отдельно проверьте, вызывает ли обработчик отмены cancel базовой задачи, а также её итоговое состояние. Отсутствие ответа от сервера само по себе не означает, что отмена сработала. При гонке между отменой и завершением допустим любой порядок завершения, соответствующий контракту, но запись бизнес-состояния после завершения недопустима.

В конце проведите проверку с намеренным внесением ошибок: удаление слабого захвата должно выявить цикл сильных ссылок, а удаление проверки идентификатора запроса — привести к падению теста ответов, пришедших не по порядку. Только способность обнаружить намеренно внесённые ошибки показывает, что тесты действительно защищают нужные границы, а не просто обеспечивают зелёный статус конвейера.

Часто задаваемые вопросы

Гарантирует ли cancel остановку сетевого запроса?

Нет. Отмена подписки и остановка базовой задачи зависят от реализации издателя. Отдельно проверяйте вызов отмены задачи и её состояние.

Как обойтись без фиксированных задержек в тестах?

Используйте PassthroughSubject для синхронных событий. Для асинхронной доставки применяйте управляемый планировщик или явный сигнал завершения вместо sleep.

Арендуйте облачный Mac для следующей сборки

Выберите модель, город и срок аренды, чтобы работать со своими инструментами разработки на выделенном физическом узле. Доступность проверяйте по актуальным данным в консоли.

Превратите чтение в следующую сборку

Сначала выберите модель и срок аренды, затем спланируйте проект.