XCTCast

The XCTest framework added the super helpful function XCTUnwrap back in Xcode 11. At the time I added a similar helper to my various projects to help smooth over the many cases where casting is required. The idea is to have a similar call site to XCTUnwrap but for use in situations where you want to verify you have the right type or fail.

This commonly happens when we are testing functions that have a return type where some of the type information is erased e.g. when a function returns a protocol or generic super class but we need to assert on a specific implementation. Another common case is if we are using mocks/doubles via subclassing, in our test we’ll often want to cast to our test type to get more information.


One such example in practice might be to check that a class implements UICollectionViewDataSource in the correct way for your application. The method that you would want to validate would be UICollectionViewDataSource.collectionView(_:cellForItemAt:) -> UICollectionViewCell. The issue we have is that the return type is UICollectionViewCell which you’ll often subclass. A safe test method might look like this:

func testFirstCellHasListing1() throws {
    let cell = controller.collectionView(collectionView, cellForItemAt: .init(item: 0, section: 0))

    guard let cell = cell as? MyCell else {
        XCTFail("Expected cell of type `MyCell` but got \(cell)")
        return
    }

    XCTAssertEqual("Item 1", cell.title)
}

In the above the middle 3 lines are just noise to handle type casting, in isolation it might not seem too bad but across a whole test suite it adds up. The above code snippet is quite wordy and it’s not uncommon to see repeated tasks performed differently overtime. Someone might come to the code base and opt for a more concise assertion that handles the casting

XCTAssertEqual("Item 1", (cell as! MyCell).title)

This is bad because if the test fails it will crash and unfortunately XCTest doesn’t handle crashes gracefully, instead of treating them as test failures it instead brings the whole test suite to a halt. It might then be wise to assume making this safer could be a case of updating to one of the following:

XCTAssertEqual("Item 1", (cell as? MyCell)?.title)
XCTAssertEqual("Item 1", try XCTUnwrap(cell as? MyCell).title)

The above lines would certainly resolve the crashing issue but the actual failures don’t really pinpoint the issue. The errors for the lines above would be something like

... -[Sample.Tests testFirstCellHasListing1] :
    XCTAssertEqual failed: ("Optional("Item 1")") is not equal to ("nil")

... -[Sample.Tests testFirstCellHasListing1] :
    XCTUnwrap failed: expected non-nil value of type "MyCell"

The first error is comparing an expected value to nil which doesn’t really hint why there is a nil without examining the code. The second error mentions the type expectation but it’s still not the main focus of the error so we’d need to inspect the code to see where the nil was introduced.


Adding our own helper

If we follow the rough design of XCTUnwrap we could end up with something like this:

extension XCTestCase {
    /// Asserts that an input can be cast to the target type and returns the cast result.
    /// - Parameters:
    ///   - input: The value to attempt to cast.
    ///   - targetType: The desired type to cast to.
    ///   - message: An optional description of the failure.
    ///   - file: The file in which failure occurred. Defaults to the file name of the test case in which this function was called.
    ///   - line: The line number on which failure occurred. Defaults to the line number on which this function was called.
    /// - Returns: A value of type `Target`, the result of casting the given `input`.
    ///   Throws: An error when the `input` cannot be cast to the type of `Target`.
    public func XCTCast<Input, Target>(
        _ input: Input,
        as targetType: Target.Type = Target.self,
        _ message: @autoclosure () -> String = "",
        file: StaticString = #file,
        line: UInt = #line
    ) throws -> Target {
        guard let result = input as? Target else {
            let description = "XCTCast failed: expected value of type \"\(targetType)\" but got \"\(type(of: input))\"" + message()
            record(
                .init(
                    type: .assertionFailure,
                    compactDescription: description,
                    sourceCodeContext: .init(
                        location: .init(
                            filePath: file,
                            lineNumber: line
                        )
                    )
                )
            )
            throw CastingError(description: description)
        }

        return result
    }
}

struct CastingError: Error {
    let description: String
}

With the above (fairly wordy) helper tucked away in our project our call site becomes:

func testFirstCellHasListing1() throws {
    let cell = controller.collectionView(collectionView, cellForItemAt: .init(item: 0, section: 0))
    XCTAssertEqual("Item 1", try XCTCast(cell, as: MyCell.self).title)
}

This ends up being fairly concise and similar to our version above that was “safe” but gave a less helpful error

- XCTAssertEqual("Item 1", try XCTUnwrap(cell as? MyCell).title)
+ XCTAssertEqual("Item 1", try XCTCast(cell, as: MyCell.self).title)

The benefits to the new helper are that it better shows our intent when reading the test and the error message gets straight to the heart of the issue

... -[Sample.Tests testFirstCellHasListing1] :
    XCTCast failed: expected value of type "MyCell" but got "UICollectionViewCell"

Conclusion

When XCTUnwrap landed it was a real reminder that I’d once again been dealing with daily paper cuts rather than looking at making my tooling better. The XCTCast in this post was a direct reaction to that and recognising other pain points that could be easily smoothed over. Even if XCTCast isn’t useful for someone else hopefully this is a good reminder to step back and look at your current pain points and take the time to improve things for future you.