1

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.

johnpitchko
  • 117
  • 2
  • 11
  • https://stackoverflow.com/a/39890916/1187415 demonstrates how to convert Decimal to Double. But note that this might lose precision, so a custom assertion might be the better solution. – Martin R Mar 14 '20 at 10:27

3 Answers3

0

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)
mag_zbc
  • 6,801
  • 14
  • 40
  • 62
  • 1
    [`init(floatLiteral:)`](https://developer.apple.com/documentation/swift/expressiblebyfloatliteral/2294405-init): “Do not call this initializer directly.” – Martin R Mar 14 '20 at 10:30
  • 1
    Another problem (compare https://stackoverflow.com/q/42781785/1187415): `print(Decimal(floatLiteral : 1.66)) // 1.6599999999999995904` – Martin R Mar 14 '20 at 10:33
  • You can subtract Decimals: `abs(totalCost - other)` or `(totalCost - other).magnitude` might be better readable. – Martin R Mar 14 '20 at 10:44
0

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.

Daniel Thorpe
  • 3,911
  • 2
  • 28
  • 28
-2

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)  
Mike Taverne
  • 9,156
  • 2
  • 42
  • 58
  • But in general you can’t use equality between decimals arrived at by calculation because of rounding and representation error. – matt Mar 14 '20 at 19:41
  • Thanks, yes it does work when leaving out the accuracy argument. However, as @matt stated, the code doesn't work for all values. Instead of using 3571.6, try 0.12345. – johnpitchko Mar 14 '20 at 19:57
  • In your question, you stated that this works: `XCTAssertTrue(position.totalCost == 3571.6)` – Mike Taverne Mar 15 '20 at 06:00
  • @matt True enough, but this question is about using `XCTAssertEqual` in the context of a specific unit test case, and my answer is valid in that context. I have written many test cases using this approach without problem because I am setting up the specific conditions. – Mike Taverne Mar 15 '20 at 06:04