Проверка отмены и времени жизни подписок 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 для следующей сборки
Выберите модель, город и срок аренды, чтобы работать со своими инструментами разработки на выделенном физическом узле. Доступность проверяйте по актуальным данным в консоли.