クラウド Mac で iOS の金額計算を検証する:Decimal と丸め規則

クラウド Mac で iOS の金額計算を検証する:Decimal と丸め規則

注文の明細を足し合わせるとサーバー側の合計と最小通貨単位で 1 だけずれる。返金額の計算でも、負数が丸めの中間値に当たると結果が食い違う。こうした不具合は、スクリーンショットだけでは検証できません。金額の解析と丸め規則を UI から純粋関数として切り出し、クラウド Mac 上で固定のテストケースを実行しましょう。クライアントとサーバーの計算仕様が一致することを確かめてから、注文画面に組み込みます。この記事で扱うのは数値のルールだけです。決済ゲートウェイには接続せず、実際の取引データも使用しません。

金額計算の仕様を先に定義する

テストを書く前に、4 つの点を決めます。入力で許容する小数桁数、決済金額として保持する小数桁数、丸めを行う段階、そしてちょうど中間値になった場合の丸め方向です。ここでは、入力は小数点以下最大 6 桁、決済金額は小数点以下 2 桁とし、中間値はゼロから遠ざかる方向に丸めます。これはあくまで仕様の一例であり、すべての業務に適用できるわけではありません。

割引率、単価、決済金額は、それぞれ性質の異なる値です。単価に数量を掛けた後も計算途中の精度を保持できますが、最終的な金額は必ず、合意した決済用の関数を通す必要があります。呼び出し側ごとに書式を整えてから数値へ戻す実装は避けてください。

Decimal が解決するのは十進数の表現の問題であり、業務上のルールを決めてくれるわけではありません。明細ごとに丸める方法も、合計してから丸める方法も妥当な場合がありますが、同じ注文の中で混在させてはいけません。

まず、サーバーが返す値が十進数の文字列なのか、最小通貨単位を表す整数なのかを確認します。実際には浮動小数点数を返している場合、先に API の仕様を見直す必要があります。すでに失われた精度を、クライアント側で元に戻すことはできません。

単独で実行できるルール用パッケージを作る

空のディレクトリに Swift のライブラリパッケージを作成し、生成されたソースファイルにルールの実装を、対応するテストディレクトリにテストを記述します。コマンドはクラウド Mac のプロジェクトディレクトリで実行します。シミュレーターを起動する必要はありません。

mkdir MoneyRules
cd MoneyRules
swift package init --type library --name MoneyRules
swift --version

Sources/MoneyRules/MoneyRules.swift を次の実装に置き換えます。解析に使うロケールを固定し、入力の長さに上限を設け、桁区切り、指数表記、空白、規定を超える小数桁数を拒否します。ここで扱うのは API のデータであり、ユーザーのロケールに応じた入力ではありません。

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 は厳密に比較し、「許容誤差」の範囲を設けないでください。許容範囲を設けると、最小通貨単位で 1 の差をテスト自体が見逃すおそれがあります。

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 を実行し、3 つのテストがすべて成功することを確認します。計算順序のテストは、どちらかの決済方法を推奨するものではありません。両者の結果が実際に異なることを示すためのものです。業務でどちらかを採用したら、注文単位のテストも追加し、呼び出しの途中で別の方法に切り替わらないようにします。

さらに、ゼロ、負のゼロ、返金、許容する最大金額に加え、中間値未満・中間値と同じ・中間値を超える 3 組のケースを追加します。中間値を偶数側へ丸める規則に変更する場合は、実装だけを変えるのではなく、対応する期待値も作り直してください。

データ送受信と画面表示を分けて検証する

API で送受信するデータを、画面表示用のフォーマッターに依存させてはいけません。表示では桁区切りを加えたり、ロケールに合わせて小数点の記号を変えたりできますが、送受信には引き続き、合意した形式の十進数文字列を使います。以下では、決済用に丸めた結果を文字列へ変換した後、正確に読み戻せるかを確認します。ただし、文字列の小数部分が必ず 2 桁になることは保証しません。

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" のような固定形式が必要な場合は、エンコーダーを別途実装してテストします。数値が等しいというだけで、文字列形式まで正しいと判断してはいけません。

クライアントとサーバーで共有するテストケースには、入力文字列、数量、丸めを行う段階、期待する文字列を保存し、実際の注文番号は保存しません。双方が同じケースを読み込んで検証することで、同じルールを使っているかを確認できます。

パイプラインに組み込み、再確認できる証拠を残す

まず独立パッケージのテストを実行し、その後でアプリのテストを実行します。独立パッケージのテストが失敗したら、以降のビルド・パッケージングを停止し、数値ルールの不具合を UI の受け入れ確認まで持ち越さないようにします。コミット識別子、Swift のバージョン、失敗したテストケースを記録してください。処理時間の数値を、正しさの判断の代わりにしてはいけません。

確認項目は短く、明確にまとめます。

Foundation だけを使うテストが成功しても、証明できるのは計算層が現在の仕様に従っていることだけです。最後には iOS のテストターゲットで画面の入力と表示を検証し、そこで見つかった最小通貨単位の差を固定のテストケースとして追加する必要があります。修正のたびに再実行できるテストを残すほうが、一度だけ行った手作業の照合結果を覚えておくよりも確実です。

よくある質問

Decimal を使えば金額の誤差はなくなりますか?

いいえ。Double を経由した変換や早すぎる丸め、処理間の規則の違いで差が出ます。検証済みの十進文字列から生成し、丸める段階を明示してください。

このテストに iOS シミュレータは必要ですか?

Foundation の純粋な計算は Swift パッケージの swift test で実行できます。画面入力、ローカライズ表示、iOS 固有の処理は別途アプリのテストで確認します。

ビルドキューを専用クラウドMacに

物理ノード、機種、利用期間を選ぶだけで、ご自身専用のmacOS環境でXcodeのビルドや自動化タスクを実行できます。実際の利用可能状況は、コンソールに表示される最新情報をご確認ください。

ビルド環境を引き続き確認

機種の仕様と操作ガイドを確認して、次の設定を決めましょう。