訂單明細逐項加總後,與伺服器端總額差了一分;退款金額在負數中點也出現偏差。這類問題不能只靠截圖驗收。應將金額解析與捨入規則從介面抽離,寫成純函式,再於雲端 Mac 執行固定測試案例,先證明兩端遵循相同的運算契約,再整合進訂單頁面。本文只處理數值規則,不連接支付閘道,也不使用真實交易資料。
先定義金額運算契約
開始測試前,先確定四件事:輸入允許幾位小數、業務結算保留幾位、在哪個步驟捨入,以及數值恰好落在中點時往哪個方向取值。以下採用最多六位輸入小數、兩位結算小數,並在中點採取遠離零的捨入方式。這只是契約範例,並非適用於所有業務。
折扣率、單價與結算金額是不同性質的數值。單價乘上數量後,可以保留中間運算的精度,但最終金額必須經過約定的結算函式。不要讓各個呼叫端自行格式化,再轉回數值。
Decimal 解決的是十進位表示問題,不會替你決定業務規則。逐項捨入與加總後捨入都可能合理,但不能在同一筆訂單中混用。
先確認伺服器端回傳的是十進位字串,還是以最小貨幣單位表示的整數。如果實際回傳的是浮點數,就必須先調整介面契約;用戶端無法還原已經因精度損失而改變的原值。
建立可獨立執行的規則套件
在空目錄中建立 Swift 函式庫套件,將規則程式碼放入產生的原始碼檔案,並將測試寫入對應的測試目錄。以下指令在雲端 Mac 的專案目錄中執行,不需要啟動模擬器。
mkdir MoneyRules
cd MoneyRules
swift package init --type library --name MoneyRules
swift --version
以以下實作取代 Sources/MoneyRules/MoneyRules.swift。固定解析時使用的地區設定、限制輸入長度,並拒絕千位分隔符、指數表示法、空白,以及超過約定小數位數的輸入。這裡處理的是介面資料,不負責使用者的在地化輸入。
import Foundation
public enum MoneyRules {
public static func parse(_ text: String) -> Decimal? {
guard text.utf8.count <= 20,
text.range(
of: #"^-?[0-9]+(?:\.[0-9]{1,6})?\z"#,
options: .regularExpression
) != nil else {
return nil
}
return Decimal(
string: text,
locale: Locale(identifier: "en_US_POSIX")
)
}
public static func settle(_ value: Decimal) -> Decimal {
var input = value
var output = Decimal()
NSDecimalRound(&output, &input, 2, .plain)
return output
}
}
不要寫成 Decimal(Double(text)!)。即使最後轉成 Decimal,中途經過二進位浮點數轉換時,數值來源就已經改變。長度上限也必須符合實際業務範圍,不能為了讓測試通過而任意截斷金額。
用中點與運算順序找出差異
將以下內容寫入 Tests/MoneyRulesTests/MoneyRulesTests.swift,取代預設測試。精確比較 Decimal,不設定「允許誤差」的容許區間,否則一分錢的差異可能反而被測試掩蓋。
import XCTest
@testable import MoneyRules
final class MoneyRulesTests: XCTestCase {
private func number(_ text: String) throws -> Decimal {
try XCTUnwrap(MoneyRules.parse(text))
}
func testMidpoints() throws {
XCTAssertEqual(
MoneyRules.settle(try number("1.005")),
try number("1.01")
)
XCTAssertEqual(
MoneyRules.settle(try number("-1.005")),
try number("-1.01")
)
XCTAssertEqual(
MoneyRules.settle(try number("1.004")),
try number("1.00")
)
}
func testRoundingStage() throws {
let item = try number("0.335")
let perLine = MoneyRules.settle(item) * Decimal(3)
let total = MoneyRules.settle(item * Decimal(3))
XCTAssertEqual(perLine, try number("1.02"))
XCTAssertEqual(total, try number("1.01"))
}
func testRejectedInputs() {
for text in ["", "1,25", "1e2", "NaN", " 1.00",
"1.0000001", "1.00\n"] {
XCTAssertNil(MoneyRules.parse(text))
}
}
}
執行 swift test,確認三個測試全部通過。運算順序測試不是在推薦某一種結算方式,而是展示兩種方式的結果確實不同。業務選定其中一種後,還應加入訂單層級的測試,防止呼叫鏈改用另一種方式。
接著補上零值、負零、退款、允許的最大金額,以及低於、等於、高於中點的三組測試案例。若改採中點取偶數的規則,應重新建立對應的預期值,而不是只修改實作。
分開驗收傳輸與顯示邊界
介面傳輸不能依賴畫面的格式化工具。顯示時可以加入分組符號,或依地區調整小數分隔符,但傳輸仍應使用約定的十進位字串。以下檢查結算結果轉成字串後,是否能精確讀回;這並不保證字串固定保留兩位小數。
import Foundation
var settled = MoneyRules.settle(MoneyRules.parse("12.345")!)
let wire = NSDecimalString(
&settled,
Locale(identifier: "en_US_POSIX")
)
precondition(MoneyRules.parse(wire) == settled)
應將這項檢查放進測試,而不是讓正式環境中的程式遇到錯誤資料就直接終止。如果協定要求 "12.30" 這類固定格式,就應另外實作並測試編碼器,不能只憑數值相等就判定字串通過驗收。
跨端共用的測試案例應保存輸入字串、數量、捨入階段與預期字串,不保存真實訂單編號。伺服器端與用戶端各自讀取同一組案例,才能確認雙方採用相同規則。
整合進流水線,保留可複查的證據
先執行獨立套件的測試,再執行應用程式測試。獨立套件測試失敗時,就停止後續打包,避免把數值規則錯誤拖到介面驗收階段。記錄提交識別碼、Swift 版本與失敗案例,不要用執行時間的數字取代正確性結論。
檢查項目應簡短明確:
- 輸入來自十進位字串,沒有經過 Double 轉換。
- 只在約定階段捨入,中間結果沒有反覆截位。
- 負數中點與退款規則已透過獨立斷言驗證。
- 傳輸值能精確讀回,顯示文字不參與運算。
- 兩端共用測試案例,規則變更時同步更新預期值與業務說明。
僅使用 Foundation 的測試通過,只能證明運算層符合目前的契約。最後仍須在 iOS 測試目標中驗證頁面輸入與顯示,並將發現的分位差異補成固定測試案例。每次修正都留下可重新執行的測試,比記住一次人工核對的結果更可靠。
常見問題
改用 Decimal 就能保證金額運算正確嗎?
不能。先經 Double 轉換、提早捨入或兩端規則不同,仍會產生差異。應從經過驗證的十進位字串建立 Decimal,並明確規定捨入時點。
金額運算測試需要啟動 iOS 模擬器嗎?
純 Foundation 計算可以放進 Swift 套件,在雲端 Mac 執行 swift test。畫面輸入、在地化顯示與 iOS 專用程式碼仍需另外執行應用程式測試。
將建置佇列交給專屬雲端 Mac
選擇實體節點、機型與計費週期,讓 Xcode 建置和自動化工作在專屬的 macOS 環境中執行。實際可用狀態請以控制台即時回報為準。