iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Vibe Coding

神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 系列 第 23 篇

[Day 23] 不用兩支手機也能測同步:用 protocol 加記憶體版的後端,在一個測試裡模擬兩個人

  • 分享至 

  • xImage
  •  

Day 23

文章同步發表在我的個人 Blog

不用兩支手機也能測同步:用 protocol 加記憶體版的後端,在一個測試裡模擬兩個人

要測兩個人之間的同步,一定要兩支手機和一個真的 Firebase 嗎?

Day 13 提過 CardRepository 有兩個實作、要通過同一組測試,今天把這句話拆開來看。

這個 App 的核心是同步:爸爸丟一件事,媽媽的手機要收到。如果每次驗證這件事都要接兩支手機,改程式的速度會慢到沒辦法做。今天講這個專案怎麼用 Swift 的 protocol 和一個記憶體版的後端,在一個測試裡模擬兩個人。

先講結果:「兩支手機」在測試裡只是兩個物件,共用一個記憶體版的後端。有 6 個測試檔、39 處用同一個輔助工具建出兩個人,一個同步測試只要幾十毫秒。

1. 問題:同步邏輯很難測

同步要測的事情很多:我存的卡片,對方收不收得到?對方按「知道了」,我這邊看不看得到?寫入被伺服器拒絕時,畫面怎麼辦?

用真的兩支手機測,要安裝、要操作、要等網路,沒辦法自動化,也沒辦法每改一行就跑一次。

2. 做法一:用 protocol 把「資料從哪裡來」切開

畫面背後的邏輯(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 的一部分)。

3. 做法二:記憶體版的「雲端」

記憶體版的 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) 的意思是:如果收的人還沒處理完,只保留最新一份清單,舊的丟掉。同步看的是「現在長什麼樣」,不需要把每一次中間狀態都排隊。

4. 做法三:兩個人就是兩個 Session,共用同一個後端

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 收到,媽媽按「知道了」,爸爸這邊看得到,整段過程沒有網路、沒有手機。

5. 非同步的測試怎麼等

上面有個 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 的隔離對不上。

6. 記憶體版會不會騙人

記憶體版的「雲端」是我自己寫的,很簡單、很順。真的 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 個。所以分工是:記憶體版負責邏輯與同步的流程,規則測試負責真實的規則行為。

7. 目前的狀態

項目 結果
App 測試 172 個
規則測試 95 個(Emulator)
用 TwoHomes 的測試 6 個檔案、39 處
兩個實作共用的 contract tests(卡片與行程) 各 7 個
單一同步測試的時間 幾十毫秒(0.013~0.024 秒)
verify.sh VERIFY: PASS

明天

Day 24 把這個做法反過來問:規則寫了這麼多條件,每一條都有測試在守嗎?

參考資源

官方文件


上一篇
[Day 22] 大字級時固定在底部的操作列吃掉半個畫面:用 isAccessibilitySize 把次要操作移進內容裡捲動
下一篇
[Day 24] 規則的每個條件都有測試守著嗎:對留言規則做變異測試,11 個條件拿掉後 9 個會讓測試失敗,找出一個真的缺口
系列文
神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言