Local unit tests with Xcode

What if I want to keep unit tests close to a tested code, and I use Xcode? I may have one idea.

Default Xcode project setup consist of product target and test target. Test target is to run tests, and you probably know that. Test target is where all test cases goes etc.

Let's build a world where we still use Xcode, but our tests are as close to tested code as possible - in the same file, in the same Xcode target.

As an excercise I implemented this imaginary Animal type, and put my code in a file Animal.swift

class Animal {
    var name: String
    init(_ name: String) {
        self.name = name.uppercased()
    }
}

with a test at the bottom of the file

// MARK - Tests

#if canImport(XCTest)
import XCTest

class AnimalTests: XCTestCase {
    func testAnimal() {
        let animal = Animal("rex")
        XCTAssertEqual(animal.name, "REX")
    }
}
#endif

At this point the project won't build and run as expected. First, because XCTest is missing, second because there's nothing to trigger the test suite.

Setup Xcode

I have to tell build system where to find XCTest.framework without linking it explicitly (Xcode will figure out and link framework by itself).

For that I modify: ENABLE_TESTING_SEARCH_PATHS = YES. Another way to do it is to modify FRAMEWORK_SEARCH_PATHS with a path to XCTest.framework

The project builds, but it won't run, because my application can't find XCTest at runtime, for that I modify LD_RUNPATH_SEARCH_PATHS (more about @rpath in my earlier post https://blog.krzyzanowskim.com/2018/12/05/rpath-what/)

I add two paths:

  • $(PLATFORM_DIR)/Developer/Library/Frameworks
  • $(PLATFORM_DIR)/Developer/usr/lib

one is for XCTest.framework, and the other is for libXCTestSwiftSupport.dylib.

Trigger test suite

The project now builds and run, but still wont execute any test - because I didn't tell it to do so. This is where small helper starts to shine. TestRunner gets default test suite - that is a suite with all tests found in runtime in this binary, and run one by one.

#if canImport(XCTest)
import XCTest

class TestRunner {
    static func run() {
        let suite = XCTestSuite.default
        for test in suite.tests {
            test.run()
        }
    }
}
#endif

The more advanced version of the runner would look somehow like the one below, where exception is thrown on failed test

#if canImport(XCTest)
import XCTest

class TestRunner {
    struct TestFailed: Swift.Error, LocalizedError {
        let message: String

        var errorDescription: String? {
            message
        }
    }

    static func run() throws {
        let suite = XCTestSuite.default
        for test in suite.tests {
            if let testRun = test.testRun {
                test.perform(testRun)
                if !testRun.hasSucceeded {
                    throw TestFailed(message: "Failed \(testRun.test.name)")
                }
            }
        }
    }
}
#endif

Finally, I need to use TestRunner at some point, eg. in applicationDidFinishLaunching or really wherever it is suitable, for example here's my code

if UserDefaults.standard.bool(forKey: "run-tests") {
    do {
        try TestRunner.run()
    } catch let error {
        fatalError(error.localizedDescription)
    }
}

I use conditional tests run, where my condition is "-run-tests" parameter. I put the parameter in my Xcode scheme configuration and now this is where I enable/disable automatic tests run

That's all folks.

Unfortunately Xcode itself wont support that. That means the visual aspect of testing won't be available out of the box.

Discussion on Twitter: https://twitter.com/krzyzanowskim/status/1310680500709855236