離開搜尋頁面後,結果仍回寫到狀態,或是反覆進入頁面後,請求愈來愈多,常見原因不是網路慢,而是 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 後,網路請求一定已經停止嗎?
不一定。cancel 會取消目前的訂閱,但底層工作是否停止取決於發布者實作。需另外驗證取消路徑是否呼叫工作取消,並確認工作端的狀態。
如何避免生命週期測試依賴固定等待時間?
先用 PassthroughSubject 同步控制事件與取消;涉及排程器時,使用可控制的排程器或明確的完成訊號。不要以固定 sleep 推測沒有事件。