KISS

Keep It Simple Stupid

Creating an XCTest extensions target

| comments

The iOS application I’m working on is complicated and consists of multiple frameworks (which correspond to targets in the Xcode project), each one for a separate piece of functionality. Therefore we also have a number of testing targets, roughly one test target per framework. XCTest is the standard testing framework for iOS applications in Xcode, however it’s quite limited, it only has a number of basic assertions. It’s useful to create our own higher-level assertions to make the testing code more expressive. Then the question becomes, how to share those common assertions between multiple test frameworks? Well, create a new framework (target) with those assertions, of course! Alas, it’s not that simple when you need to import XCTest in that framework. I’ll show here how I made it work.

Setup

The sample project with the step-by-step commit history is available here: https://github.com/eunikolsky/XCTestExtensions. In commit cafb9a1ea07906e269bc812f87d4ec34090cf9bd we have a test target FrameworkTests with one test testMagicNumberShouldBeWithinCertainBounds() that uses a test helper XCTAssertWithinBounds():

1
2
3
4
5
6
7
8
9
func testMagicNumberShouldBeWithinCertainBounds() {
    let magicNumber = Framework.magicNumber
    XCTAssertWithinBounds(magicNumber, 8...9000, "The magic number should really be within the specified bounds to work")
}

/// Asserts that `x` is within the given `bounds`.
func XCTAssertWithinBounds <T: Comparable> (_ x: T, _ bounds: ClosedRange<T>, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) {
    XCTAssertTrue(bounds.contains(x), "Expected \(x) to be within the bounds \(bounds): \(message())", file: file, line: line)
}

We want to move this assertion to a shared framework. We’ll create a new Framework target and move our helpers file there, but it doesn’t build now:

1
2
3
XCTestExtensions/XCTestExtensions/XCTestExtensions.swift:9:8: error: cannot load underlying module for 'XCTest'
import XCTest
       ^

Using basic assertions

As you see the problem is in importing XCTest in a regular framework. Linking with XCTest doesn’t work.

The fix is to set “Framework Search Paths” (FRAMEWORK_SEARCH_PATHS) of the target to: "$(PLATFORM_DIR)/Developer/Library/Frameworks" and $(inherited) (the inherited item should be there if you’re using CocoaPods because it sets its own paths list, and it’s a good idea for compatibility anyway). See commit 1f514f8dfa6f3721b21ca4766803575b10d9321e. Build succeeded!

This works for most of the standard assertions like XCTAssert, XCTAssertEqual, XCTAssertNoThrow, but not if you want to use XCTUnwrap.

Using XCTUnwrap

If you need to assert that an Optional value is not nil in tests and get its unwrapped value, use XCTUnwrap:

1
2
3
4
5
/// Asserts that the string `s` is at least of the length `minCount`. If `s` is `nil`, the assertion fails.
func XCTAssertStringHasMinimalCount(_ s: String?, _ minCount: Int, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) throws {
    let s = try XCTUnwrap(s, "Expected string to have at least \(minCount) characters, but it was nil: \(message())", file: file, line: line)
    XCTAssertGreaterThanOrEqual(s.count, minCount, "Expected string \(s) to have at least \(minCount) characters, but it had \(s.count): \(message())", file: file, line: line)
}

However this will not work in our test extensions framework:

1
2
3
XCTestExtensions/XCTestExtensions/XCTestExtensions.swift:18:17: error: use of unresolved identifier 'XCTUnwrap'
    let s = try XCTUnwrap(s, "Expected string to have at least \(minCount) characters, but it was nil: \(message())", file: file, line: line)
                ^~~~~~~~~

I tried linking the framework with XCTest and/or libswiftXCTest.tbd, but neither worked. So where is this function defined?

1
2
3
4
5
6
7
8
$ rg -F XCTUnwrap /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/XCTest.swiftmodule/armv7-apple-ios.swiftinterface
14:public func XCTUnwrap<T>(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) throws -> T

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/usr/lib/XCTest.swiftmodule/arm.swiftinterface
14:public func XCTUnwrap<T>(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line) throws -> T

<…skipped…>

Whereas XCTAssertTrue is mentioned more times:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ rg -F XCTAssertTrue /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework/Headers/XCTestAssertions.h
72: * @define XCTAssertTrue(expression, ...)
77:#define XCTAssertTrue(expression, ...) \

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/lib/swift/libswiftXCTest.tbd
11:                       '_$s6XCTest13XCTAssertTrue__4file4lineySbyKXK_SSyXKs12StaticStringVSutF',

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/lib/swift/XCTest.swiftmodule/arm64e.swiftinterface
15:public func XCTAssertTrue(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line)

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/usr/lib/swift/XCTest.swiftmodule/arm64.swiftinterface
15:public func XCTAssertTrue(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line)

<…skipped…>

So XCTUnwrap isn’t present in the Objective-C headers, but only in the swift module — that must be the cause of the build error. There are two steps to fix this:

  1. Set “Import Paths” (in “Swift Compiler – Search Paths”) (SWIFT_INCLUDE_PATHS) to "$(PLATFORM_DIR)/Developer/usr/lib" and $(inherited) (just in case). Now the code compiles, but linking fails:

     ld: warning: Could not find or use auto-linked library 'XCTestSwiftSupport'
     Undefined symbols for architecture x86_64:
       "XCTest.XCTAssertTrue(_: @autoclosure () throws -> Swift.Bool, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) -> ()", referenced from:
           XCTestExtensions.XCTAssertWithinBounds<A where A: Swift.Comparable>(_: A, _: Swift.ClosedRange<A>, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) -> () in XCTestExtensions.o
       "XCTest.XCTAssertGreaterThanOrEqual<A where A: Swift.Comparable>(_: @autoclosure () throws -> A, _: @autoclosure () throws -> A, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) -> ()", referenced from:
           XCTestExtensions.XCTAssertStringHasMinimalCount(_: Swift.String?, _: Swift.Int, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) throws -> () in XCTestExtensions.o
       "XCTest.XCTUnwrap<A>(_: @autoclosure () throws -> A?, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) throws -> A", referenced from:
           XCTestExtensions.XCTAssertStringHasMinimalCount(_: Swift.String?, _: Swift.Int, _: @autoclosure () -> Swift.String, file: Swift.StaticString, line: Swift.UInt) throws -> () in XCTestExtensions.o
       "__swift_FORCE_LOAD_$_XCTestSwiftSupport", referenced from:
           __swift_FORCE_LOAD_$_XCTestSwiftSupport_$_XCTestExtensions in XCTestExtensions.o
          (maybe you meant: __swift_FORCE_LOAD_$_XCTestSwiftSupport_$_XCTestExtensions)
     ld: symbol(s) not found for architecture x86_64
     clang: error: linker command failed with exit code 1 (use -v to see invocation)
    
  2. Set “Library Search Paths” (in “Search Paths”) (LIBRARY_SEARCH_PATHS) to the same two values: "$(PLATFORM_DIR)/Developer/usr/lib" and $(inherited). See commit aef611b773b8ee79720774238ef586058e9edcd9. Build succeeded!

    I’ve found a hint for this step here: Why linker link static libraries with errors? iOS.

All good now.

Comments