雲端 Mac 上的 iOS 列表排序契約與回歸測試

雲端 Mac 上的 iOS 列表排序契約與回歸測試

檔案列表裡有「報告2」和「報告10」,重新整理後,兩個同名項目又交換了位置。問題不一定出在介面更新,也可能是比較函式沒有定義完整的順序。在雲端 Mac 上執行 iOS 測試時,把排序邏輯從檢視層抽出來,先用小樣本驗收規則,比對著錄影猜測更容易重現問題。

先明確定義排序契約

本文採用一套檔案列表規則:名稱由小到大排序,數字按自然順序比較,忽略大小寫與重音;名稱比較結果相等時,再按持久化的業務識別碼由小到大排序。比較時使用固定的地區設定,避免測試結果隨機器設定改變。

這不是所有產品都適用的預設答案。使用者的通訊錄可能需要依目前語言排序;跨裝置共用的檔案列表則可能要求一致的順序。先確定需求,再撰寫實作,不要把「看起來順眼」當成驗收標準。

穩定識別碼必須在重新整理前後保持不變。陣列索引、接收時間,以及每次建立的新 UUID,都不能用來決定同名項目的先後順序。資料匯入時也應拒絕重複識別碼,否則連最後一道排序鍵都無法區分項目。

穩定排序只能保留輸入中相等項目的相對順序,無法修正伺服器每次傳回不同順序的問題。

將比較函式獨立為純函式

將以下檔案加入應用程式目標。範例固定使用 en_US_POSIX,代表這裡選擇了固定的比較策略,並不表示它適合所有語言的名稱顯示需求。

import Foundation

struct ListEntry {
    let id: String
    let name: String
}

func entryLess(_ lhs: ListEntry, _ rhs: ListEntry) -> Bool {
    let order = lhs.name.compare(
        rhs.name,
        options: [.numeric, .caseInsensitive, .diacriticInsensitive],
        range: nil,
        locale: Locale(identifier: "en_US_POSIX")
    )
    if order != .orderedSame {
        return order == .orderedAscending
    }
    return lhs.id < rhs.id
}

不要在比較過程中讀取網路狀態、修改計數器,或查詢可能變動的使用者設定。一次排序會多次比較同一物件,結果必須一致。也不要用「小於或等於」實作比較函式:物件不能排在自己之前。

名稱比較結果等價,不代表是同一個業務物件。「Resume」和「résumé」在目前策略下可能被歸為同一組,但不能因此合併紀錄。排序、搜尋和去除重複資料,是三種不同的契約。

用小樣本固定關鍵行為

在單元測試檔案中匯入應用程式模組,再加入測試類別。程式碼應放在能存取上述型別的測試目標內;將 ListSortingTests 中的樣本擴充為自己的檔案名稱即可。

import XCTest

final class ListSortingTests: XCTestCase {
    let items = [
        ListEntry(id: "d", name: "Report10"),
        ListEntry(id: "b", name: "report2"),
        ListEntry(id: "a", name: "REPORT2"),
        ListEntry(id: "c", name: "Report1")
    ]

    func testNaturalOrderAndTies() {
        XCTAssertEqual(
            items.sorted(by: entryLess).map(\.id),
            ["c", "a", "b", "d"]
        )
    }

    func testComparatorProperties() {
        for a in items {
            XCTAssertFalse(entryLess(a, a))
            for b in items {
                if entryLess(a, b) {
                    XCTAssertFalse(entryLess(b, a))
                }
                for c in items {
                    if entryLess(a, b) && entryLess(b, c) {
                        XCTAssertTrue(entryLess(a, c))
                    }
                }
            }
        }
    }

    func testInputOrderDoesNotChangeOutput() {
        let expected = items.sorted(by: entryLess).map(\.id)
        XCTAssertEqual(
            Array(items.reversed()).sorted(by: entryLess).map(\.id),
            expected
        )
        let rotated = Array(items.dropFirst()) + [items[0]]
        XCTAssertEqual(
            rotated.sorted(by: entryLess).map(\.id),
            expected
        )
    }
}

第一個測試驗收數字順序與同名項目的排序。第二個檢查反自反性、非對稱性與傳遞性,能找出臨時拼湊多種規則所造成的矛盾。第三個改變輸入順序,驗證結果不依賴資料的接收順序。

三層迴圈適合十幾個具代表性的樣本,不要直接套用到完整的正式環境列表。大型資料集應另外測量排序時間,不能把性質檢查誤當成效能基準測試。

補齊 Unicode 與空值的邊界情況

接著加入空名稱、前導零、中文名稱、組合重音和表情符號序列。預組字元與「字母加組合重音」可能顯示成相同的樣子;不能用字串長度判斷它們的顯示順序。

對於「檔案02」和「檔案2」,不要自行假設數字比較的結果。先明確定義產品是否將它們視為同一組,再於選定的執行環境中驗證。如果必須區分前導零,可以在名稱比較結果等價後,加入明確的次級名稱規則,最後仍以業務識別碼決定順序。

缺少名稱的情況應在模型轉換階段統一處理,例如轉換為空字串,或定義「缺少名稱的項目排在最後」的分支。不要在比較函式中隨機補入預設值。降冪排序也應重新定義排序鍵的方向,不能直接將升冪比較結果取反;取反會讓相等物件被判定為「小於」。

在雲端執行並記錄失敗證據

在雲端 Mac 的測試工作中,固定 Xcode、模擬器執行環境與比較所用的地區設定,透過既有的單元測試入口執行這些測試。保存提交識別碼、工具鏈版本、失敗時的輸入,以及實際輸出的識別碼序列,不需要記錄真實客戶的檔案名稱。

先驗收純函式,再驗收介面:連續重新整理兩次,確認同名項目不會交換位置;刪除一個項目後再插入,確認其餘項目的相對順序符合規則。介面層必須重用同一個比較函式,不能另外撰寫一套排序邏輯。

固定地區設定不代表系統的定序規則永遠不變。升級執行環境後,應重新檢查具代表性的語言樣本;如果差異來自系統規則,就決定要接受變更,還是採用自訂排序鍵,而不是直接更新所有快照。

最終交付內容應包含排序契約、代表性樣本、比較函式的性質檢查,以及重新整理的驗收結果。這樣下次順序改變時,就能判斷是資料、規則還是執行環境發生變化,而不是又從截圖開始猜測。

常見問題

排序演算法穩定,就能避免同名項目跳動嗎?

不能。穩定排序保留的是輸入中的相對順序;若伺服器回傳順序改變,同名項目仍會移動。應以持久且唯一的業務識別碼作為最後的排序鍵。

固定比較地區後,可以永久沿用同一份排序快照嗎?

不宜。固定地區能排除使用者設定差異,但系統字串定序規則仍可能隨執行環境更新。升級時應重新檢查代表性資料與比較函式的性質。

為下一次建置租用雲端 Mac

選好機型、城市和租期,透過專屬實體節點執行開發工具。可用狀態請以控制台即時回報為準。

讓閱讀成為下一次建置的起點

先確認機型與租期,再安排專案。