Проверка расчётов сумм в iOS: Decimal и правила округления
Сумма позиций заказа отличается от итога на сервере на одну копейку, а при расчёте возврата возникает расхождение на отрицательном значении ровно посередине между двумя результатами округления. Такие ошибки нельзя проверять по скриншотам. Вынесите разбор денежных значений и правила округления из интерфейса в чистые функции и прогоните фиксированный набор примеров на облачном Mac. Сначала убедитесь, что клиент и сервер соблюдают один и тот же расчётный контракт, и только потом подключайте эти функции к экрану заказа. В статье рассматриваются только числовые правила — без подключения платёжного шлюза и без реальных данных о транзакциях.
Сначала определите контракт денежных расчётов
До написания тестов нужно ответить на четыре вопроса: сколько знаков после запятой допускается на входе, сколько сохраняется в итоговой сумме, на каком этапе выполняется округление и в какую сторону округляется значение, находящееся ровно посередине. В примере ниже допускается до шести знаков после запятой на входе, итоговая сумма округляется до двух знаков, а значения ровно посередине округляются от нуля. Это лишь пример контракта, а не универсальное правило для любого бизнеса.
Ставка скидки, цена за единицу и итоговая сумма — разные величины. После умножения цены на количество можно сохранить промежуточную точность, но итоговую сумму обязательно нужно пропустить через согласованную функцию расчёта. Не позволяйте каждому вызывающему участку кода самостоятельно форматировать сумму и преобразовывать её обратно в число.
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 точно, без допуска на погрешность: иначе тест может сам скрыть расхождение в одну копейку.
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 и убедитесь, что все три теста проходят. Тест порядка расчётов не рекомендует какой-либо способ округления: он показывает, что результаты действительно различаются. После выбора бизнес-правила добавьте тесты на уровне заказа, чтобы цепочка вызовов не могла незаметно перейти на другой способ.
Дополните набор примерами с нулём, отрицательным нулём, возвратами и максимально допустимой суммой, а также тремя группами значений: ниже середины интервала, ровно на ней и выше неё. Если вы переходите на округление к ближайшему чётному, заново определите ожидаемые результаты, а не ограничивайтесь изменением реализации.
Проверяйте передачу и отображение отдельно
Передача данных через API не должна зависеть от форматирования интерфейса. Для отображения можно добавлять разделители групп разрядов или выбирать десятичный разделитель в зависимости от региона, но для передачи по-прежнему должна использоваться согласованная десятичная строка. Следующая проверка подтверждает, что результат расчёта можно преобразовать в строку и прочитать обратно без потери точности. Она не гарантирует наличие ровно двух знаков после десятичного разделителя.
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, а найденные расхождения в сотых долях денежной единицы добавить в фиксированный набор примеров. Воспроизводимый тест после каждого исправления надёжнее, чем память о разовой ручной сверке.
Часто задаваемые вопросы
Достаточно ли заменить Double на Decimal?
Нет. Преобразование через Double и преждевременное округление сохраняют ошибки. Создавайте Decimal из проверенной десятичной строки и явно задавайте момент округления.
Нужен ли симулятор iOS для этих тестов?
Чистые вычисления на Foundation можно проверять командой swift test в пакете Swift. Ввод в интерфейсе, локализованное отображение и код только для iOS проверяются отдельно.
Передайте очередь сборки выделенному облачному Mac
Выберите физический узел, модель Mac и период оплаты. Запускайте сборки Xcode и задачи автоматизации в собственной среде macOS. Актуальный статус доступности отображается в консоли.