'Using Foundation Decimal type in XCTAssertEqual

I am building some classes and code that store and perform arithmetic on currency values. I was originally using Doubles, but converted to Decimal due to arithmetic errors.

I am trying to find the best way to run unit tests against functions working with Decimal type.

Consider position.totalCost is a Decimal type.

XCTAssertEqual(position.totalCost, 3571.6, accuracy: 0.01)

This code does not compile because Decimal does not conform to FloatingPoint. XCTAssertEqual requires parameters to be Doubles or Floats.

I got around this by doing the following:

XCTAssertTrue(position.totalCost == 3571.6)

Which does work, but if an error arises during the unit test, I get a vague message:

XCTAssertTrue failed rather than the more useful XCTAssertEqual failed: ("2.0") is not equal to ("1.0")

So using XCTAssertEqual is ideal.

Potential Options (as a novice, no clue which is better or viable)

  1. Code my Position class to store all properties as Decimal but use computed properties to get and set them as Doubles.

  2. Write a custom assertion that accepts Decimals. This is probably the most 'proper' path because the only issue I've encountered so far with using Decimals is that XCT assertions cannot accept them.

  3. Write a goofy Decimal extension that will return a Double value. For whatever reason, there is no property or function in the Decimal class that returns a Double or Floag.



Solution 1:[1]

Don't convert Decimal to a floating point if you don't have to since it will result in a loss of precision. If you want to compare two Decimal values with some accuracy you can use Decimal.distance(to:) function like so:

let other = Decimal(35716) / Decimal(10) // 3571.6
let absoluteDistance = abs(position.totalCost.distance(to: other))
let accuracy = Decimal(1) / Decimal(100) // 0.01
XCTAssertTrue(absoluteDistance < accuracy)

Solution 2:[2]

You can write an extension on Decimal:

extension Decimal {

    func isEqual(to other: Decimal, accuracy: Decimal) -> Bool {
        abs(distance(to: other)).isLess(than: accuracy)
    }
}

And then use it in your tests:

XCTAssertTrue(position.totalCost.isEqual(to: 3571.6, accuracy: 0.01))

This is likely good enough. However, to get better error messages in the case of a failing test would require writing an overload for XCTAssertEqual, which is actually a bit tricky because elements of XCTest are not publicly available.

However, it is possible to approximate the behaviour:

Firstly, we need some plumbing to evaluate assertions, this can more or less be lifted straight from swift-corelibs-xctest.

import Foundation
import XCTest

internal enum __XCTAssertionResult {
    case success
    case expectedFailure(String?)
    case unexpectedFailure(Swift.Error)

    var isExpected: Bool {
        switch self {
        case .unexpectedFailure: return false
        default: return true
        }
    }

    func failureDescription() -> String {
        let explanation: String
        switch self {
        case .success: explanation = "passed"
        case .expectedFailure(let details?): explanation = "failed: \(details)"
        case .expectedFailure: explanation = "failed"
        case .unexpectedFailure(let error): explanation = "threw error \"\(error)\""
        }
        return explanation
    }
}

internal func __XCTEvaluateAssertion(testCase: XCTestCase, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line, expression: () throws -> __XCTAssertionResult) {
    let result: __XCTAssertionResult
    do {
        result = try expression()
    }
    catch {
        result = .unexpectedFailure(error)
    }

    switch result {
    case .success: return
    default:
        let customMessage = message()
        let description = customMessage.isEmpty ? result.failureDescription() : "\(result.failureDescription()) - \(customMessage)"
        testCase.record(.init(
            type: .assertionFailure,
            compactDescription: description,
            detailedDescription: nil,
            sourceCodeContext: .init(
                location: .init(filePath: String(describing: file), lineNumber: Int(line))
            ),
            associatedError: nil,
            attachments: [])
        )
    }
}

Now, for all of this to work, requires us to have access to the currently running XCTestCase, inside a global XCTAssert* function, which is not possible. Instead we can add our assert function in an extension.

extension XCTestCase {

    func AssertEqual(
        _ expression1: @autoclosure () throws -> Decimal,
        _ expression2: @autoclosure () throws -> Decimal,
        accuracy: @autoclosure () throws -> Decimal, 
        _ message: @autoclosure () -> String = "",
        file: StaticString = #file,
        line: UInt = #line
    ) {
        __XCTEvaluateAssertion(testCase: self, message(), file: file, line: line) {
            let lhs = try expression1()
            let rhs = try expression2()
            let acc = try accuracy()
            guard lhs.isEqual(to: rhs, accuracy: acc) else {
                return .expectedFailure("(\"\(lhs)\") is not equal to (\"\(rhs)\")")
            }
            return .success
        }
    }
}

All of which allows us to write our test cases as follows...

class MyTests: XCTestCase {
  // etc
  func test_decimal_equality() {
    AssertEquals(position.totalCost, 3571.6, accuracy: 0.01)
  }
}

And if the assertion fails, the test case will fail, with the message: ("3571.5") is not equal to ("3571.6") at the correct line.

We also cannot call our function XCTAssertEquals, as this will override all the global assert functions.

You milage may vary, but once you have the plumbing in place, this allows you to write bespoke custom assertions for your test suite.

Solution 3:[3]

Do you really need to specify the accuracy of 0.01?

Because if you omit this argument, it compiles just fine.

struct Position {
    let totalCost: Decimal
}

let position = Position(totalCost: 3571.6)

//passes
XCTAssertEqual(position.totalCost, 3571.6)  

// XCTAssertEqual failed: ("3571.6") is not equal to ("3571.61")
XCTAssertEqual(position.totalCost, 3571.61)  

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Daniel Thorpe
Solution 3 Mike Taverne