Test iOS Money Calculations with Decimal on a Cloud Mac
An order’s line items add up to one cent more or less than the server total, and a refund rounds incorrectly at a negative midpoint. Screenshots cannot validate these failures. Extract amount parsing and rounding rules from the UI into pure functions, then run a fixed set of test cases on a cloud Mac. First establish that both sides follow the same calculation contract; only then connect the rules to the order screen. This article covers numerical rules only, without connecting to a payment gateway or using real transaction data.
Define the money calculation contract first
Before testing, settle four questions: how many decimal places the input may contain, how many the final amount retains, when rounding happens, and which direction to round an exact midpoint. The example below allows up to six decimal places in the input, rounds final amounts to two decimal places, and rounds ties away from zero. This is an example contract, not a rule that fits every business.
Discount rates, unit prices, and final amounts are different kinds of quantities. You can preserve intermediate precision after multiplying a unit price by a quantity, but the final amount must go through the agreed settlement function. Do not let each caller format the amount independently and convert it back to a number.
Decimal handles decimal representation; it does not decide your business rules. Rounding each line item and rounding the combined total can both be valid, but they must not be mixed within the same order.
First confirm whether the server returns decimal strings or integers in the smallest currency unit. If it returns floating-point numbers, adjust the API contract first: the client cannot recover an original value once precision has been lost.
Create a standalone rules package
Create a Swift library package in an empty directory. Put the rules in the generated source file and the tests in the corresponding test directory. Run these commands in the project directory on the cloud Mac; there is no need to launch a simulator.
mkdir MoneyRules
cd MoneyRules
swift package init --type library --name MoneyRules
swift --version
Replace Sources/MoneyRules/MoneyRules.swift with the implementation below. It uses a fixed parsing locale, limits input length, and rejects grouping separators, exponent notation, whitespace, and decimal places beyond the agreed limit. This code handles API data, not localized user input.
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
}
}
Do not use Decimal(Double(text)!). Even if the final result is a Decimal, the intermediate binary floating-point conversion has already changed how the source value is represented. The length limit must also match the actual range your business supports; do not arbitrarily truncate amounts to make tests pass.
Expose differences with midpoint and calculation-order tests
Write the following code to Tests/MoneyRulesTests/MoneyRulesTests.swift, replacing the default tests. Compare Decimal values exactly, without an “acceptable error” tolerance that could hide a one-cent discrepancy.
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))
}
}
}
Run swift test and confirm that all three tests pass. The calculation-order test does not recommend one settlement method over the other; it demonstrates that the results really do differ. Once the business chooses a method, add order-level tests to prevent the call chain from switching to the other one.
Extend the cases to cover zero, negative zero, refunds, the maximum allowed amount, and three groups of values: below, exactly at, and above the midpoint. If you switch to ties-to-even rounding, establish the corresponding expected results again rather than changing only the implementation.
Validate transport and display separately
API transport must not depend on a UI formatter. Display formatting may add grouping separators or use a region-specific decimal separator, while transport must continue to use the agreed decimal string format. The following check verifies that a settled amount can be converted to a string and parsed back exactly. It does not guarantee that the string always has two decimal places.
import Foundation
var settled = MoneyRules.settle(MoneyRules.parse("12.345")!)
let wire = NSDecimalString(
&settled,
Locale(identifier: "en_US_POSIX")
)
precondition(MoneyRules.parse(wire) == settled)
Put this check in a test instead of terminating the production program when it encounters invalid data. If the protocol requires a fixed format such as "12.30", implement and test a separate encoder. Numerical equality alone is not enough to validate the string.
Shared client-server test cases should store the input string, quantity, rounding stage, and expected string, not real order IDs. Both sides must read the same set of cases to confirm that they apply the same rules.
Add a regression gate to the pipeline and keep reviewable evidence
Run the standalone package tests first, then the application tests. If the package tests fail, stop the remaining build steps so that numerical rule errors do not reach UI acceptance testing. Record the commit identifier, Swift version, and failing cases. Execution times are not a substitute for a correctness verdict.
Keep the checklist short and explicit:
- Inputs come from decimal strings without passing through Double.
- Rounding happens only at the agreed stage, with no repeated truncation of intermediate results.
- Negative midpoint cases and refund rules have their own assertions.
- Transport values round-trip exactly, and display text does not participate in calculations.
- Both sides share test cases, and rule changes update the expected values and business documentation together.
Passing Foundation-only tests proves only that the calculation layer follows the current contract. You still need to verify screen input and display in an iOS test target, then add any cent-level discrepancies you find to the fixed test cases. Leaving a rerunnable test after every fix is more reliable than remembering the outcome of a one-off manual check.
Frequently asked questions
Does using Decimal automatically make money calculations correct?
No. Converting through Double, rounding too early, or using different rules on either side can still produce discrepancies. Parse validated decimal strings and define when rounding occurs.
Do these tests require an iOS simulator?
Pure Foundation calculations can run with swift test in a Swift package on the cloud Mac. UI input, localized display, and iOS-only code need separate application tests.
Hand Off Build Queues to Your Dedicated Cloud Mac
Choose a physical node, Mac model, and billing term to run Xcode builds and automation in your own macOS environment. Check the console for real-time availability.