雲端 Mac 上的 iOS 浮點資料邊界測試:NaN、無窮值與容許誤差
進度列偶爾不更新、統計圖突然一片空白,問題可能不在 UI 執行緒,而是上游傳入了 NaN。將感測器讀值除以量測範圍,或將完成量除以總量,都可能產生無效結果。在 ArmShell 雲端 Mac 上,可以先將這層運算拆成獨立的 Swift 套件,以邊界測試攔下異常,再整合進 iOS 專案。
先訂定數值契約,而不是先調整容許誤差
本文只處理一項明確的業務需求:將非負讀值正規化到零至一之間。輸入必須是有限值,量測範圍必須大於零,結果也必須是有限值且不得超出範圍;無效資料一律回報錯誤,不自動截斷。零讀值是有效輸入,負零則統一輸出為正零。
這裡有個容易遺漏的條件:value < 0 || value > 1 無法排除 NaN。NaN 的有序比較都會回傳 false,程式可能繼續將它視為有效結果。
有限性、業務範圍與近似相等,是三個不同層次的檢查。放寬測試的容許誤差,無法修正無效輸入。
事先明訂錯誤處理策略也很重要:介面可以顯示「資料無法使用」,但不能悄悄將失敗轉換為零,否則使用者就無法區分真正的零值與讀取失敗。
建立不依賴模擬器的測試套件
先確認命令列工具可用,再於暫存目錄建立套件。以下操作不會修改應用程式專案,也不需要啟動圖形介面工作階段:
xcrun swift --version
root="$(mktemp -d)"
cd "$root"
swift package init --type library --name NumericBoundary
將 Sources/NumericBoundary/NumericBoundary.swift 的內容替換為:
public enum LevelError: Error, Equatable {
case invalidInput
case invalidResult
}
public func normalizedLevel(
_ raw: Double,
scale: Double
) throws -> Double {
guard raw.isFinite, scale.isFinite,
raw >= 0, scale > 0 else {
throw LevelError.invalidInput
}
let ratio = raw / scale
guard ratio.isFinite, ratio >= 0, ratio <= 1 else {
throw LevelError.invalidResult
}
return ratio == 0 ? 0.0 : ratio
}
不能只驗證輸入。兩個有限值相除,仍可能得到無窮值,例如極大的分子除以極小的正分母。另一方面,即使結果是有限值,只要超出量測範圍,也應判定為失敗,不能仰賴介面元件替運算層收拾問題。
這段實作允許正常的浮點捨入;若業務還要求最小有效讀值,就需要另設門檻。極小正數相除可能下溢為零,本例接受這種情況,不將「有限」誤當成「完全沒有精度損失」。
用反例涵蓋異常路徑
將預設測試檔案的內容替換為以下程式碼。每個案例都會檢查錯誤類別,避免函式因其他原因拋出錯誤時,測試仍被誤判為通過:
import XCTest
@testable import NumericBoundary
final class NumericBoundaryTests: XCTestCase {
func testInvalidInputs() {
let cases: [(Double, Double)] = [
(.nan, 1),
(.infinity, 1),
(-.infinity, 1),
(1, .nan),
(1, .infinity),
(1, -.infinity),
(-1, 1),
(1, 0),
(1, -0.0),
(1, -1)
]
for (raw, scale) in cases {
XCTAssertThrowsError(
try normalizedLevel(raw, scale: scale)
) { error in
XCTAssertEqual(error as? LevelError, .invalidInput)
}
}
}
func testInvalidResults() {
let cases: [(Double, Double)] = [
(2, 1),
(.greatestFiniteMagnitude, .leastNormalMagnitude)
]
for (raw, scale) in cases {
XCTAssertThrowsError(
try normalizedLevel(raw, scale: scale)
) { error in
XCTAssertEqual(error as? LevelError, .invalidResult)
}
}
}
func testValidBoundaries() throws {
XCTAssertEqual(try normalizedLevel(0, scale: 1), 0)
XCTAssertEqual(try normalizedLevel(1, scale: 1), 1)
XCTAssertEqual(
try normalizedLevel(1, scale: 10),
0.1,
accuracy: 1e-12
)
let zero = try normalizedLevel(-0.0, scale: 1)
XCTAssertEqual(zero, 0)
XCTAssertEqual(zero.sign, .plus)
}
}
在一般的相等比較中,負零等於正零,因此必須額外檢查 sign,才能證明正規化策略確實生效。異常案例也不要使用「結果不等於 NaN」的斷言:NaN 連自身都不相等,這種測試無法證明任何事。
容許誤差必須依據業務尺度設定
範例的輸出範圍固定為零至一,採用絕對誤差比較較為直觀。1e-12 是針對這段簡單運算選定的測試容許誤差,並非適用於所有感測器、圖形或統計工作的通用精度標準。
如果運算值的跨度很大,應先確認兩個值都是有限值,再依業務需求結合絕對誤差與相對誤差:
abs(actual - expected)
<= max(absoluteTolerance,
relativeTolerance * max(abs(actual), abs(expected)))
兩個容許誤差本身也必須是有限且非負的值。在極端數量級下,差值或右側的乘法可能溢位,因此不能直接將這個公式視為通用且安全的函式庫實作。
接近零時,主要由絕對誤差控制;數值很大時,則主要由相對誤差控制。測試程式碼應說明門檻的依據,例如裝置解析度或演算法的誤差預算,而不是不斷放寬門檻,直到測試通過。也不要先將數值格式化為小數字串再比較,這樣會掩蓋實際差異。
納入流水線並驗證品質關卡有效
在套件目錄執行:
swift test
swift test -c release
偵錯與發行組態都必須執行測試。正式環境的驗證應使用 guard 與拋出錯誤,而不是可能在最佳化建置中被移除的 assert。流水線必須保留命令的結束代碼,只要測試失敗,就停止後續交付。
接著做一次破壞性驗證:暫時移除輸入檢查中的 raw.isFinite,確認 NaN 案例會因錯誤類別改變而失敗,再還原程式碼。這樣才能證明測試確實涵蓋輸入端的檢查,而不只是執行過函式。
整合進應用程式時,應讓業務程式碼呼叫同一份實作,不要在測試套件中另行維護副本。記錄工具鏈版本、提交識別碼與失敗案例即可,不必上傳真實使用者的讀值。最終驗收標準是:異常輸入明確失敗、有效邊界穩定通過、容許誤差有依據,而且在發行組態下仍執行相同的契約。
常見問題
只檢查數值是否小於零或大於一,能排除 NaN 嗎?
不能。NaN 與零、一進行有序比較時都會得到 false,必須先用 isFinite 排除 NaN 和正負無窮,再檢查範圍。
輸入都是有限數,還需要檢查運算結果嗎?
需要。有限數相除仍可能溢位為無窮值,運算結果必須再次檢查 isFinite,並確認符合業務允許的範圍。