3

Suppose I have:

protocol MyError: Error, Equatable {
  var errorDispalyTitle: String { get }
  var errorDisplayMessage: String { get }
}

enum ContentState {
  case .loading
  case .error(any MyError)
  case .contentLoaded
}

If i were to implement Equatable in ContentState so I can compare during unit tests I end up with a problem because I have to compare two any MyError types which are boxed and might be of two different underlying types.

extension ContentState: Equatable {
  static func == (lhs: ContentState, rhs: ContentState) -> Bool {
    switch (lhs, rhs) {
    case (.loading, .loading):
      return true
    case (.contentLoaded, .contentLoaded):
      return true
    case (.error(let lhsError), .error(let rhsError)):
      // TODO: have to compare if both underlying types are match and then if they are equal in value
    default:
      return false
    }
  }
}

How do I do this?

I tried lifting the generic from the existential type there to the type (e.g. ContentState<Error: MyError>) which lets it compile when implementing equatable as it knows how to infer the type, but the problem is for whichever class uses that enum it doesnt matter which type is receiving of it, only that is any type of it, and if I don't implement the any existential it starts requiring the generic to be propagated up the chain.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
corujautx
  • 33
  • 3

3 Answers3

2

You can write a wrapper that wraps an Equatable, and wrap the LHS and RHS errors before comparing.

// resembling a similar behaviour to AnyHashable...
class AnyEquatable: Equatable {
    let value: Any
    private let equals: (Any) -> Bool

    init<E: Equatable>(_ value: E) {
        self.value = value
        self.equals = { type(of: $0) == type(of: value) && $0 as? E == value }
    }

    static func == (lhs: AnyEquatable, rhs: AnyEquatable) -> Bool {
        lhs.equals(rhs.value)
    }
}

Then in your switch you can do:

case (.error(let lhsError), .error(let rhsError)):
  return AnyEquatable(lhsError) == AnyEquatable(rhsError)

Note that if MyError inherits from Hashable instead of Equatable, you can use the builtin AnyHashable instead of writing your own AnyEquatable.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • Thanks! Why is it an `||` instead of an `&&` though? – corujautx Feb 24 '23 at 02:58
  • @corujautx Interesting. I'm surprised I didn't notice that while answering that question. That said, I don't think it matters too much (?). I think in most cases `lhs.equals(rhs.value)` would agree with `rhs.equals(lhs.value)`. The `AnyHashable` even compares one direction only. This implementation *might* potentially have issues with optional types, e.g. a `Optional.none` and a `Optional>.none` would be considered as equal because they both unwrap to `Optional.none`. – Sweeper Feb 24 '23 at 03:29
  • @corujautx See the edit. I have made it behave in a similar way to `AnyHashable`. Note that `AnyHashable` does not behave in the same way as regular `==`, especially with regards to optionals and inheritance. This is probably the simplest way to make an `AnyEquatable`, and probably not very good in terms of performance, which is why I still recommend using `AnyHashable`. – Sweeper Feb 24 '23 at 03:57
  • Impressive approach. The `type(of` check is redundant though because `$0 as? E == value` implies the check. – vadian Feb 24 '23 at 08:09
  • @vadian Actually removing that would change the behaviour, `as?` would sometimes successfully cast even when the types are different, like when dealing with optional types and inheritance. Since I'm trying to get to something similar to `AnyHashable`, I wouldn't want a `Dog` and `Animal` to be considered equal, just because `Animal.==` says they are. While trying to get a similar behaviour to `==` is also a valid approach (probably not so much in the context of unit testing?), that also has its own difficult parts to implement. – Sweeper Feb 24 '23 at 08:28
1

As of Swift 5.7, Swift automatically “opens” an existential when you pass it as an argument of generic type. The implicit self argument can be opened (in fact Swift has always opened the self argument), and Swift can open multiple arguments in a single invocation. So we can write an isEqual(to:) function that compares any Equatable to any other Equatable like this:


extension Equatable {
    func isEqual<B: Equatable>(to b: B) -> Bool {
        return b as? Self == self
    }
}

And then we can complete your ContentState conformance like this:

extension ContentState: Equatable {
  static func == (lhs: ContentState, rhs: ContentState) -> Bool {
    switch (lhs, rhs) {
    case (.loading, .loading):
      return true
    case (.contentLoaded, .contentLoaded):
      return true
    case (.error(let lhsError), .error(let rhsError)):
        return lhsError.isEqual(to: rhsError)
    default:
      return false
    }
  }
}
rob mayoff
  • 375,296
  • 67
  • 796
  • 848
0

Four unit testing, I would recommend extending your type with Bool's as much as possible:

extension ContentState {
    var isLoading: Bool {
        if case .loading = self { return true }
        else { return false }
    }
    
    var isError: Bool {
        if case .error = self { return true }
        else { return false }
    }
    
    var isContentLoaded: Bool {
        if case .contentLoaded = self { return true }
        else { return false }
    }
}

, as I assume you're mostly interested in testing if some object properly updates its state.

This makes unit tests easier to write and read, and they better transmit the intent.

If you want to also explicitly check the error, you could make use of generics:

extension ContentState {
    func wrappedError<T: MyError>() -> T? {
        if case let .error(error as T) = self { return error }
        else { return nil }
    }
}

, and assert on that:

XCTAssertEqual(state.wrappedError(), TestError.error1)
Cristik
  • 30,989
  • 25
  • 91
  • 127