When do you really want to compare errors? → In the unit tests.
Imagine this scenario. By now you should be aware of how important Result<T>
is. If not,
you can review the concept with the post Result type in-depth blog.
func testWhenResultWithFailureIsMapped_thenOutputIsResultWithInitialFailure() {
let failInt = Result<Int>.failure(error: IntError.testError)
let output = failInt.map { "\($0)" }
switch output {
case let .failure(error: e):
let expected = IntError.testError
XCTAssertTrue(e == expected) // == cant be applied to operands of Error
case .success:
XCTFail("mapping over failed type should propagate error")
}
}
Where IntError is simply:
enum IntError: Error {
case testError
case unknown(String)
}
Q: How can I compare 2 instances of Error type properly?
First let’s see what Error and NSError looks like behind the scene.
Expectation
What we really want to do by the end of this post is this. Its much readable and involves less code.
func testWhenResultWithFailureIsMapped_thenOutputIsResultWithInitialFailure() {
let failInt = Result.failure(error: IntError.testError)
let output = failInt.map { "\($0)" }
XCTAssertTrue(areEqual(output.error!, IntError.testError))
}
Inside Error
Error is a protocol defined in ErrorType.swift file in Swift Standard Library. _domain and _code are both private properties and hence for the public side its just a empty protocol.
public protocol Error {
var _domain: String { get } // private
var _code: Int { get } // private
}
Inside NSError
Each NSError object encodes three critical pieces of information: a `status` code, corresponding to a particular error `domain`, as well as additional context provided by a `userInfo` dictionary.1
open class ApproxNSError{
open var code: Int
open var domain: String
open var userInfo: [String : Any]
}
NSError is bridged to Error
Take a example of `JSONSerialization` Obj-C API which would take `NSError` pointer. This API is interpolated to Swift with `try..catch` whereas `try..catch` uses Error not the NSError. NSError is bridged to Error.
func test_malformedJSONDataCannotBeDeSerialized() {
let jsonData = "{ \"name\": \"Swift\"".data(using: .utf8)!
do {
let json = try JSONSerialization.jsonObject(with: jsonData, options: .allowFragments)
} catch {
print(error)
// NSError is bridged to Error
// Error Domain=NSCocoaErrorDomain Code=3840
// "Unexpected end of file while parsing object."
}
}
Equality on NSError
Equality on NSError is given by isEqual()
API which is inhabited on every single NSObject subtype.
let er = NSError(domain: "a", code: 1, userInfo: ["name": "something"])
let er2 = NSError(domain: "a", code: 1, userInfo: ["name": "something"])
let erd = NSError(domain: "b", code: 2, userInfo: nil)
er.isEqual(er2) // true
er.isEqual(erd) // false
This looks great.
Equality on Error
- We can’t extend `Error` to conform to `Equatable` as Error is a protocol.
extension Error: Equatable { // Extension of `protocol` cant have inheritance clause
}
- Lets try using the bridge to NSError and comparing there.
let (e1,e2) = (IntError.testError, IntError.testError)
(e1 as NSError).isEqual(e2 as NSError) // true
let (e3,e4) = (IntError.unknown("float"), IntError.unknown("hex"))
(e3 as NSError).isEqual(e3 as NSError) // true
// NOTE
(e3 as NSError).isEqual(e4 as NSError) // trure :: should be false
The solution kind of works but does provide false positive for Error types with associated values. It is because the associated values are solely for swift purpose, they are not bridged to Obj-C. There is no API that represents the associated values in `NSError`. However, rest assure that the metadata is kept safe if you were to bridge back to `Error` type again.
let flaotUnknown = IntError.unknown("Float")
let floatUknownNS = (flaotUnknown as! NSError).copy() as! NSError
floatUknownNS.code // 0
floatUknownNS.domain // "__lldb_expr_29.IntError"
floatUknownNS.userInfo // [:]
// associated value is only representable with Swift enum types
let backToErr = floatUknownNS as! Error // unknown("Float")
-
This begs the fact that equality has to be done in 2 levels. Equality of Error by bridging to NSError and Equality on associated values of Error.
-
One clever way to check for associated values are identical in
Error
is by swift standard representation of types withString(describing:)
. As we know there can’t be two cases ofparseFailed
cases, the types of associated values are irrelevant to be checked for.
let parseFailed = IntError.parseFailed(column: 10, row: 30, reason: "Expected digit but got {")
let parseFailedEOF = IntError.parseFailed(column: 0, row: 0, reason: "Unexpected EOF")
let pf1 = String(describing: parseFailed) // "parseFailed(column: 10, row: 30, reason: "Expected digit but got {")"
let pf2 = String(describing: parseFailedEOF)
pf1 == pf2 // false
Equality for Error
// Equality on instance of same typed Errors
public func areEqual<T: Erorr>(_ lhs: T, _ rhs: T) -> Bool {
// Swifty check
guard String(describing: lhs) == String(describing: rhs) else {
return false
}
// Sanity check
let nsl = lhs as NSError
let nsr = rhs as NSError
return nsl.isEqual(nsr)
}
- Swifty check depends on the fact that
T
either doesn’t conform toCustomStringConvertible
or thatCustomStringConvertible
conformance does the right job. There is no way of making sure something like this can happen.
extension IntError: CustomStringConvertible {
var description: String {
return "Case independent string"
}
}
// without sanity check this would be TRUE
areEqual(IntError.testError, IntError.unknown("a"))
-
Sanity check relies on the point 1 where there might be programmer error on conforming to correct
CustomStringConvertible
. In such case, we want to gracefully check for bridgedNSError
comparison. -
Is there a better way? Sure there is!
Reflection to the Rescue
A better idea would be do the reflection on Error and compare the reflected values.
This is neither affected by improper implementation of CustomStringConvertible
nor we need to do
additional sanity check on NSError
.
/**
This is a equality on any 2 instance of Error.
*/
public func areEqual(_ lhs: Error, _ rhs: Error) -> Bool {
return lhs.reflectedString == rhs.reflectedString
}
public extension Error {
var reflectedString: String {
// NOTE 1: We can just use the standard reflection for our case
return String(reflecting: self)
}
// Same typed Equality
public func isEqual(to: Self) -> Bool {
return self.reflectedString == to.reflectedString
}
}
public extension NSError {
// prevents scenario where one would cast swift Error to NSError
// whereby losing the associatedvalue in Obj-C realm.
// (IntError.unknown as NSError("some")).(IntError.unknown as NSError)
public func isEqual(to: NSError) -> Bool {
let lhs = self as Error
let rhs = to as Error
return self.isEqual(to) && lhs.reflectedString == rhs.reflectedString
}
}
Conclusion
I knowingly went through the post in a exploration manner where I showed you what Error
and NSError
types look like. How are they bridged and How one might be tempted to use String(describing:)
to compare them but might run into issues if Error
instances conformed to malformed CustomStringDescription
protocol. We finally had a solid reflection based 1 liner that would work in all the case. During the time of writing the blog, I also made a pull request to the Apple Swift
github repo (Bonus point for me 🤓) on ErrorType.swift file.
With that information, now its time you can compare `Error` correctly and write nice unit test case scenario.
I hope you liked the format of this post rather than presenting you the answer right away. Should there be any comments, suggestions or issues feel free to DM me at https://www.twitter.com/kandelvijaya or comment down below. Enjoy coding!