
文章同步發表在我的個人 Blog
要測兩個人之間的同步,一定要兩支手機和一個真的 Firebase 嗎?
Day 13 提過 CardRepository 有兩個實作、要通過同一組測試,今天把這句話拆開來看。
這個 App 的核心是同步:爸爸丟一件事,媽媽的手機要收到。如果每次驗證這件事都要接兩支手機,改程式的速度會慢到沒辦法做。今天講這個專案怎麼用 Swift 的 protocol 和一個記憶體版的後端,在一個測試裡模擬兩個人。
先講結果:「兩支手機」在測試裡只是兩個物件,共用一個記憶體版的後端。有 6 個測試檔、39 處用同一個輔助工具建出兩個人,一個同步測試只要幾十毫秒。
同步要測的事情很多:我存的卡片,對方收不收得到?對方按「知道了」,我這邊看不看得到?寫入被伺服器拒絕時,畫面怎麼辦?
用真的兩支手機測,要安裝、要操作、要等網路,沒辦法自動化,也沒辦法每改一行就跑一次。
畫面背後的邏輯(ViewModel)不直接碰 Firebase,只認一個 protocol:
public protocol CardRepository: Sendable {
func cards() -> AsyncStream<[Card]> // live snapshots
func latest() async throws -> [Card]
func save(_ card: Card) async throws
func delete(_ id: CardID) async throws
}
它只規定「有哪些動作」,沒有規定資料存在哪裡。這個介面有兩種實作:
| 實作 | 資料在哪裡 | 誰用 |
|---|---|---|
| Firestore 版 | Firebase | App 平常執行 |
| 記憶體版 | 電腦的記憶體 | 單元測試、UI 測試、Debug 的展示模式 |
「Firebase 的程式碼只能出現在一個資料夾」不是口頭約定:scans.sh 有一項掃描,只要別的資料夾出現 import Firebase… 就失敗(Day 14 提過的 verify.sh 的一部分)。
記憶體版的 repository 本身很薄,它把工作交給一個共用的 InMemoryBackend。這個後端是一個 actor,裡面有家庭、成員、卡片,還有一份「誰在監聽」的名單:
public actor InMemoryBackend {
private var cards: [HouseholdID: [CardID: Card]] = [:]
private var cardObservers: [HouseholdID: [UUID: AsyncStream<[Card]>.Continuation]] = [:]
func save(_ card: Card, in household: HouseholdID, by member: MemberID) throws {
try requireMember(member, of: household) // only members may write
cards[household, default: [:]][card.id] = card
publishCards(of: household) // push to every observer
}
}
actor 保證同一時間只有一個地方在改這份資料,兩個「手機」同時存取也不會互相踩。監聽的做法是 AsyncStream:每個要看卡片的人,向後端登記一個 continuation,後端有變動就對所有人 yield 新的清單:
public func cards() -> AsyncStream<[Card]> {
let (stream, continuation) = AsyncStream<[Card]>.makeStream(bufferingPolicy: .bufferingNewest(1))
// register this observer; remove it when the stream ends
...
return stream
}
bufferingNewest(1) 的意思是:如果收的人還沒處理完,只保留最新一份清單,舊的丟掉。同步看的是「現在長什麼樣」,不需要把每一次中間狀態都排隊。
App 裡,Session 負責把資料流轉成畫面看得到的狀態:它開幾個 Task,用 for await 一直讀 repository 送來的清單,更新自己的 cards;Session 是 @Observable,所以 cards 一變,畫面就跟著更新。
測試用的輔助工具 TwoHomes 就是建兩份:
let backend = InMemoryBackend(clock: clock) // one shared "cloud"
// alice and bob each get their own Session + HomeViewModel,
// both pointing at the same backend
兩個人各有一個 Session 和 HomeViewModel,共用同一個 backend,就等於兩支手機連到同一個雲端。測試寫起來像這樣(Day 18 發現的缺口之一,修好之後的測試):
let home = try await TwoHomes.make(now: now)
let card = try await add(home.alice, "Buy diapers", role: .partner, startAt: nil)
try await eventually("bob 收到") { home.bob.pendingCards.map(\.id) == [card.id] }
await home.bob.acknowledge(try XCTUnwrap(home.bob.pendingCards.first))
XCTAssertEqual(home.alice.unscheduledCards.map(\.id), [card.id])
爸爸存一張卡片,媽媽的 ViewModel 收到,媽媽按「知道了」,爸爸這邊看得到,整段過程沒有網路、沒有手機。
上面有個 eventually。ViewModel 的資料是「後端送來、Task 收到、再更新」,不是存完立刻就變,所以測試不能存完就馬上檢查。固定睡 1 秒再檢查太慢,也不穩;這個專案寫了一個小工具,每 10 毫秒檢查一次條件,最多等 3 秒:
@MainActor
func eventually(_ description: String, timeout: Duration = .seconds(3),
_ condition: () -> Bool) async throws {
let deadline = ContinuousClock.now + timeout
while !condition() {
guard ContinuousClock.now < deadline else { XCTFail("逾時:\(description)"); throw CancellationError() }
try await Task.sleep(for: .milliseconds(10))
}
}
條件成立就立刻往下走,所以多數情況只等幾毫秒;萬一永遠不成立,3 秒後會失敗,而且會說明在等什麼。
另外一個 Swift 6 的細節:這個專案預設所有型別都在主執行緒(MainActor),所以測試類別、TwoHomes、eventually 都要標 @MainActor,不然會跟 ViewModel 的隔離對不上。
記憶體版的「雲端」是我自己寫的,很簡單、很順。真的 Firebase 的行為複雜得多。如果假的跟真的不一樣,測試全部通過,真的 App 還是可能出問題,這就是「記憶體版騙人」。這個專案用兩個方法防範。
第一層:同一組驗證,假的和真的都要過
先寫一個驗證,內容是「爸爸存一張卡片,媽媽那邊要收到」:
static func otherPhoneReceivesCard(_ phones: TwoPhones) async throws {
let card = sampleCard(by: phones.alice)
let bobStream = phones.bobCards.cards()
try await phones.aliceCards.save(card) // alice saves
_ = try await firstValue(from: bobStream, "bob receives") { $0.contains(card) } // bob gets it
}
這個驗證只是一個函式,不知道自己跑在哪個版本上。它被兩個測試類別呼叫:一個用記憶體版建出「兩支手機」,一個用本機的 Firebase Emulator 建出「兩支手機」(真的 Firestore,用兩個匿名身分登入)。同一個驗證,會對假的跑一次、對真的跑一次:
| 測試類別 | 「兩支手機」是什麼 |
|---|---|
InMemoryCardRepositoryContractTests |
兩個記憶體版 repository,共用 InMemoryBackend |
FirestoreCardRepositoryContractTests |
兩個真的 Firestore 連線,連本機 Emulator |
如果記憶體版說「收得到」,真的 Firebase 卻說「收不到」,Firestore 那邊的測試就會失敗,我就知道假的和真的有落差,要去修。這類驗證(卡片與行程)兩邊各有 7 個,內容包括存下來能原樣讀回、覆寫同一個 id、刪除、另一支手機收得到等等。
第二層:故意做出會出錯的版本
有些情況,正常的記憶體版測不到,因為它從來不出錯。最典型的是 Firestore 的離線優先:App 存檔時,不等伺服器確認,立刻當作成功;如果伺服器事後說「這個寫入不被規則允許」,畫面要能還原,並提醒使用者。
為了測這件事,TwoHomes 提供一個「壞版本」的 repository:它包住真的記憶體版,但 save 會立刻回報成功、資料卻根本沒進後端,模擬「本機先成功、伺服器事後才拒絕」。測試再手動通知「伺服器拒絕了」:
let acknowledged = await home.alice.acknowledge(card)
XCTAssertTrue(acknowledged) // saved locally, so it counts as success
XCTAssertTrue(home.alice.pendingCards.isEmpty) // optimistic: the card leaves "待你知道"
hub.report(WriteRejection(cardID: card.id, operation: .save)) // the server says no, later
try await eventually("顯示被拒絕的提示") { home.alice.toast == .writeRejected }
XCTAssertEqual(home.alice.pendingCards.map(\.id), [card.id]) // the card comes back
這段測試在說:alice 按下「知道了」,畫面立刻當作成功,卡片先從她的「待你知道」消失;接著伺服器拒絕,畫面要顯示提示,卡片重新出現;而且 bob 那邊的資料沒有被改成「已知」,不會誤以為 alice 已經知道。另外還有一個壞版本是 save 一定丟出錯誤,用來測「寫入失敗時畫面怎麼辦」。
限制要寫清楚
記憶體版的 save 只檢查「是不是這個家庭的成員」,不檢查卡片欄位的規則(例如留言只能追加)。這些規則要靠 Emulator 上的規則測試驗證,目前是 95 個。所以分工是:記憶體版負責邏輯與同步的流程,規則測試負責真實的規則行為。
| 項目 | 結果 |
|---|---|
| App 測試 | 172 個 |
| 規則測試 | 95 個(Emulator) |
用 TwoHomes 的測試 |
6 個檔案、39 處 |
| 兩個實作共用的 contract tests(卡片與行程) | 各 7 個 |
| 單一同步測試的時間 | 幾十毫秒(0.013~0.024 秒) |
verify.sh |
VERIFY: PASS |
Day 24 把這個做法反過來問:規則寫了這麼多條件,每一條都有測試在守嗎?
官方文件