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