XCTAssertEqual and XCTAssertNotEqual don’t use the correct equality function for a subclass
Closed this issue · 2 comments
When comparing two instances of a subclass, if both the subclass and its parent class conform to Equatable
with both having their own equality operator function (static func ==
), XCTAssertEqual and XCTAssertNotEqual will use the superclass’ function rather than the subclass function.
Xcode: 15.2
macOS: 14.3.1
Sample test file showing the error:
import XCTest
// `XCTAssertEqual` and `XCTAssertNotEqual` ignore the `==` operator on the
// class of the objects being compared in favor of the `==` operator from the
// superclass.
final class BrokenEqualityAssertionTests: XCTestCase {
private class SuperExperiment: Equatable {
var superValue = ""
static func == (lhs: SuperExperiment, rhs: SuperExperiment) -> Bool {
lhs.superValue == rhs.superValue
}
}
private class SubExperiment: SuperExperiment, Equatable {
var value: Int = 0
static func == (lhs: SubExperiment, rhs: SubExperiment) -> Bool {
lhs.value == rhs.value
}
}
// 👍 Correct results:
func test_inheritedEquality_whenSameValues() throws {
let one = SubExperiment()
let two = SubExperiment()
let same = one == two
XCTAssertTrue(same) // 1: ✅
XCTAssertEqual(one, two) // 2: ✅
XCTAssertNotEqual(one, two) // 3: ❌ (👍) Expected
}
// 🚫 Wrong results:
func test_inheritedEquality_whenNotEqual() throws {
let one = SubExperiment()
one.value = 1
let two = SubExperiment()
two.value = 2
let same = one == two
XCTAssertFalse(same) // 4: ✅
XCTAssertEqual(one, two) // 5: ✅ (🚫) Should fail instead
XCTAssertNotEqual(one, two) // 6: ❌ (🚫) Should pass instead
}
// 🚫 Wrong results:
func test_inheritedEquality_whenSameValuesButParentNotSame() throws {
let one = SubExperiment()
one.superValue = "one"
one.value = 0
let two = SubExperiment()
two.superValue = "two"
two.value = 0
let same = one == two
XCTAssertTrue(same) // 7: ✅
XCTAssertNotEqual(one, two) // 8: ✅ (🚫) Should fail instead
XCTAssertEqual(one, two) // 9: ❌ (🚫) Should pass instead
}
}
FYI your example code does not compile for me as I get the diagnostic:
🛑 Redundant conformance of 'BrokenEqualityAssertionTests.SubExperiment' to protocol 'Equatable'
XCTAssertEqual()
simply calls ==
(well, !=
, but that calls through to ==
for types conforming to Equatable
.)
This is a general constraint of protocol conformances on classes in Swift: the superclass is the type that conforms, and subclasses are not able to override the superclass' conformance when it involves a reference to Self
, because the override's type (Self
) won't match.
What's happening here is that the ==
operator implemented in the subclass is valid, in isolation, independent of Equatable
conformance, and when you explicitly write one == two
the compiler type-checks the expression and says "==(lhs: SubExperiment, rhs: SubExperiment)
is the best match among overloads of ==
".
On the other hand, when you call XCTAssertEqual()
, it is relying on Equatable
conformance and, as a generic implementation, cannot see the overload of ==
in the subclass. It can only see the overload of ==
that satisfies the parent class' Equatable
conformance, i.e. ==(lhs: SuperExperiment, rhs: SuperExperiment)
.
The solution depends on your real-world code: if these classes are actually subclasses of NSObject
, then override isEqual(_:)
instead of ==
. If these classes are not subclasses of NSObject
, then you can emulate what NSObject
does by adding an open
member to the base class and having ==
call it. However, be aware that when dealing with classes and inheritance, it can be difficult to ensure that the symmetric property of ==
is preserved if the base class is not aware of all possible subclasses.
Oh, well. Thanks for looking into it. In the end, I gave up on using inheritance and reworked the whole thing with protocols instead. 🤷