雲端 Mac 上的 iOS 升級版本判定:SemVer 邊界測試
雲端 Mac 上的自動化測試全部通過,用戶端卻把 1.10.0 判定為比 1.9.0 更舊。這不是建置故障,而是升級判斷採用了字串排序。另一個常見問題,是把解析失敗當成「不需要更新」;伺服器一旦傳回格式錯誤的資料,更新入口就悄悄消失。
以下將版本判定劃出獨立的測試邊界:先嚴格解析,再比較優先順序,最後套用升級政策。所有測試案例都在本機執行,不依賴網路,也不需要簽署資產。
先定義哪些版本可以進入比較
本文處理的是遵循 SemVer 的業務版本,格式為三個數字區段,可附加預發行識別碼與建置中繼資料,例如 2.4.0-rc.1+build.7。這不是適用於各種應用程式發佈平台版本欄位的通用驗證器,不能直接取代 CFBundleShortVersionString 的發佈規則。
核心數字區段不允許前置零,預發行部分的純數字識別碼也一樣。預發行識別碼與中繼資料只能包含 ASCII 字母、數字和連字號,以點分隔的每個區段都不能為空。中繼資料允許數字前置零,且不參與優先順序比較。
「版本較新」不等於「必須升級」。最低支援版本、分批推出範圍、測試通道與版本撤回都屬於政策層,不應塞進比較器。
先約定:無效輸入應回報解析失敗,而不是退回 0.0.0;正式環境的輸入入口也應限制字串長度,例如最多 256 位元組,避免異常資料占用不必要的資源。
實作不經整數轉換的比較器
將下列程式碼放入測試 Target 可存取的 Swift 檔案。數字區段先比較長度,再比較 ASCII 順序,因此不必將超長數字轉換為 Int。建置中繼資料通過驗證後就會捨棄,自動合成的相等判斷也就符合「優先順序相同」的語意。
struct SemVersion: Comparable {
let core: [String]
let prerelease: [String]
private static func numeric(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy { (48...57).contains($0) }
}
private static func validNumber(_ value: String) -> Bool {
numeric(value) && (value == "0" || !value.hasPrefix("0"))
}
private static func validIdentifier(_ value: String) -> Bool {
!value.isEmpty && value.utf8.allSatisfy {
(48...57).contains($0) || (65...90).contains($0)
|| (97...122).contains($0) || $0 == 45
}
}
private static func fields(_ value: String) -> [String] {
value.split(separator: ".", omittingEmptySubsequences: false)
.map(String.init)
}
init?(_ raw: String) {
guard raw.utf8.count <= 256 else { return nil }
let parts = raw.split(
separator: "+", omittingEmptySubsequences: false
).map(String.init)
guard parts.count <= 2 else { return nil }
if parts.count == 2 {
guard Self.fields(parts[1]).allSatisfy(Self.validIdentifier)
else { return nil }
}
let version = parts[0].split(
separator: "-", maxSplits: 1,
omittingEmptySubsequences: false
).map(String.init)
let core = Self.fields(version[0])
guard core.count == 3, core.allSatisfy(Self.validNumber)
else { return nil }
let pre = version.count == 2 ? Self.fields(version[1]) : []
guard pre.allSatisfy({
Self.validIdentifier($0)
&& (!Self.numeric($0) || Self.validNumber($0))
}) else { return nil }
self.core = core
self.prerelease = pre
}
private static func numberLess(_ lhs: String, _ rhs: String) -> Bool {
lhs.count == rhs.count ? lhs < rhs : lhs.count < rhs.count
}
static func < (lhs: Self, rhs: Self) -> Bool {
for (a, b) in zip(lhs.core, rhs.core) where a != b {
return numberLess(a, b)
}
if lhs.prerelease.isEmpty { return false }
if rhs.prerelease.isEmpty { return true }
for (a, b) in zip(lhs.prerelease, rhs.prerelease) where a != b {
let an = numeric(a)
let bn = numeric(b)
if an && bn { return numberLess(a, b) }
if an != bn { return an }
return a < b
}
return lhs.prerelease.count < rhs.prerelease.count
}
}
這裡不會自動去除空白,也不接受 v 前綴。嚴格的輸入入口不應偷偷修正通訊協定。如果現有介面允許這些寫法,應在獨立的相容層中轉換,並記錄轉換規則,避免用戶端與伺服器各自猜測。
用固定案例鎖定優先順序
至少涵蓋四類:核心數字遞增、正式版高於預發行版、數字識別碼低於非數字識別碼,以及前綴相同時,較長的預發行識別碼清單優先順序較高。下列測試可直接放進單元測試檔案;若實作位於應用程式模組,請補上該模組的測試匯入。
import XCTest
final class SemVersionTests: XCTestCase {
func testPrecedence() throws {
let ordered = [
"1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-alpha.beta",
"1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11",
"1.0.0-rc.1", "1.0.0", "1.9.0", "1.10.0",
"999999999999999999999999.0.0"
]
for (a, b) in zip(ordered, ordered.dropFirst()) {
let left = try XCTUnwrap(SemVersion(a))
let right = try XCTUnwrap(SemVersion(b))
XCTAssertLessThan(left, right)
XCTAssertFalse(right < left)
}
}
func testMetadataAndInvalidInput() throws {
let first = try XCTUnwrap(SemVersion("1.2.3+001"))
let second = try XCTUnwrap(SemVersion("1.2.3+build.9"))
XCTAssertEqual(first, second)
for raw in [
"", "1.2", "01.2.3", "1.2.3-01", "1.2.3-",
"1.2.3+", "1.2.3+a..b", "1.2.3+a+b",
" 1.2.3", "1.2.3\n", "1.2.3"
] {
XCTAssertNil(SemVersion(raw), raw)
}
}
}
不要只測試相鄰版本。還要補上任意三個版本之間的遞移性檢查:若甲小於乙、乙小於丙,甲就必須小於丙。同時確認任何版本都不小於自身,以及優先順序相同的兩個輸入都不小於對方。相較於單一升級案例,這些性質更容易揪出排序器的缺陷。
將比較結果交給政策層
解析失敗不代表不需要升級
業務介面最好明確回傳三種結果:可比較、目前版本無效、遠端版本無效。遠端資料出錯時,應保留目前應用程式的功能,並產生可供搜尋的錯誤事件;不要將它轉換成「目前已是最新版本」。強制升級政策若遇到無效的最低版本,應走事先定義的故障處理分支,而不是臨時猜測。
測試通道也必須獨立判斷。正式通道不應只因為 2.0.0-beta.1 高於 1.9.0,就提示安裝預發行版。先篩選允許的通道,再比較版本,最後套用分批推出與最低支援版本規則。
重新封裝同一版本時,中繼資料可用來追蹤,但不能藉此改變 SemVer 順序。如果需要更新這類產物,應另外儲存發佈序號、測試其單調性,並明確定義是否允許跨通道比較。
在雲端節點留下可複查的驗收結果
將這些測試加入現有共用 Scheme 的單元測試入口,並沿用專案固定的模擬器目標。記錄原始碼提交、工具鏈版本、測試結果與失敗案例,不需要連線至正式環境的更新介面。如此一來,同一次提交在本機與雲端驗證的就是同一份契約。
合併前請檢查:所有有效案例都能解析;沒有任何無效輸入在修正後被放行;中繼資料不影響優先順序;超長數字不會溢位;解析錯誤不會顯示成「不需要更新」;正式通道不會誤選預發行版本。
最後,用幾種刻意改壞的實作驗證測試本身:將數字比較改成字典序、讓中繼資料參與排序、允許前置零。每次修改都應讓對應測試失敗。合併前的檢查機制能抓住這些錯誤,才算真正守住升級入口,而不只是讓測試面板多一行綠色。
常見問題
可以直接用字串排序判定是否需要升級嗎?
不可以。字串排序無法正確處理 1.10.0 與 1.9.0,也不理解預發行優先序。應先嚴格解析,再逐項比較版本組成。
建置中繼資料不同就應該提示升級嗎?
SemVer 不會把加號後的建置中繼資料納入優先序。如需區分同版本重新打包的產物,應另外保存發行序號並明訂升級政策。