August 8, 2020
I've been talking a fair amount recently about how I've been writing a lot of unit tests for Fluency, my Confluence editor app, and how much I value having good tests to catch regressions and verify that the editor is actually working correctly. In the case of Fluency, I now have 70+ tests which test various parts of the app, and I've extracted various utils out of the actual XCTestCase
s into functions like assertRoundTrip(xml:)
, which takes in Confluence Storage Format XML, renders it to an NSAttributedString
, reads it back, and asserts that the resulting xml is the same as what was passed in - a crucial test when you want to make sure your editor isn't going to break any page storage just by saving.
Today, I took the next step by writing some more complicated tests: I wanted to sample 100+ valid Confluence pages from various places on the web and assertRoundTrip
on them, to give myself a reasonable amount of confidence that my editor could handle "real world" inputs. Some of these pages were quite large, and as a result the tests would take a long time to run, so I opted to put them in a separate test target so that I could run all the "unit" tests separately from the "integration" tests.
This all seemed fine, but I ran into a road block - assertRoundTrip
had been declared in my original test target, so it wasn't available in the new one. After struggling for a few hours, I was finally (with some help) able to figure out a way to extract my test utils into a shared library, but it wasn't at all straightforward. In this post I'll describe the method I used so that you can implement the same thing if it works for your project.
The sample code for this article is available at NGSharedTestUtilsTargetExample. If you're just interested in a list of steps to create the shared library, skip to here.
Let's assume a fairly standard setup for iOS app development: we have an app called ExampleApp
, and if we checked the "Include Unit Tests" checkbox when we created it in Xcode, we have a unit test target called ExampleAppTests
. For purposes of example, let's say we have a container type in our app:
struct Container {
let value1: String
let value2: String
}
And in our tests, we've defined a util to test it (assertContainerWorks
is less complicated than assertRoundTrip
, but the same idea):
// ExampleAppTests.swift
import XCTest
@testable import ExampleApp
func assertContainerWorks() {
let container = Container(value1: "abc", value2: "abc")
XCTAssertEqual(container.value1, container.value2)
}
class ExampleAppTests: XCTestCase {
func testExample() throws {
assertContainerWorks()
}
}
Now, we want to add another test target which uses the same util. Right-clicking ExampleAppTests in the text navigator and choosing "New Unit Test Target" gets us that target, which we can call OtherTests
:
Our OtherTests.swift
looks like:
import XCTest
@testable import ExampleApp
class OtherTests: XCTestCase {
func testExample() throws {
assertContainerWorks()
}
}
But the issue is that this doesn't compile, since assertContainerWorks
is defined in ExampleAppTests
, not OtherTests
.
Our goal is to extract assertContainerWorks
into a shared library (we'll call it SharedTestUtils
) such that:
assertContainerWorks
SharedTestUtils
itself should be able to use types from ExampleApp
(i.e. it should be able to @testable import ExampleApp
)If you'd like to practice this part or compare it to your own local Xcode setup, this commit is the starting point before the shared library has been created.
The way we'll approach this is to create SharedTestUtils
as a static library (for more on why we need a static library as opposed to another kind of library, see the next section). The first step is to create the new target:
SharedTestUtils.swift
gets created automatically - we can fill it in with our util (and since it's now in another module, it has to be public
):
// SharedTestUtils.swift
import XCTest
@testable import ExampleApp
public func assertContainerWorks() {
let container = Container(value1: "abc", value2: "abc")
XCTAssertEqual(container.value1, container.value2)
}
Now let's go to work by telling Xcode to run the tests:
(This section goes into detail about how to triage and resolve each issue I ran into - I'm hoping this is helpful for folks who, like me, weren't experienced at building and linking static libraries. If you're looking for a laundry list of what to do to make it work, skip to here.)
Use of unresolved identifier 'assertContainerWorks'
Makes sense - now that we have assertContainerWorks
in a separate library, we need to import the library's Swift module. import SharedTestUtils
works, so our tests now look like this:
import XCTest
@testable import ExampleApp
import SharedTestUtils
class ExampleAppTests: XCTestCase {
func testExample() throws {
assertContainerWorks()
}
}
No such module 'SharedTestUtils'
We haven't told Xcode that our test targets depend on SharedTestUtils
being built, so it hasn't built it for us. We'll need to add SharedTestUtils
as a dependency in the Xcode project settings (Build Phrases section) for both test targets:
Command CompileSwift failed with a nonzero exit code
This one is a little harder: looking at the build log in the report navigator, we see the real error: Failed to load module 'XCTest'
.
This is because we haven't linked the SharedTestUtils
library against XCTest
. We'll need to go to the project settings for SharedTestUtils
, Build Phases, Link Binary With Libraries and select XCTest
from the sheet.
No such module 'ExampleApp'
We haven't specified that the ExampleApp
target is a dependency of SharedTestUtils
, so we'll need to add that via Xcode settings as well.
Undefined symbol: SharedTestUtils.assertContainerWorks() -> ()
We've gotten past the compiler errors and we're now on to linker errors. SharedTestUtils
got compiled, but our test targets aren't linking against it, so the linker doesn't know where to find the executable code for assertContainerWorks
. This can be solved in Xcode project settings too - under "Link Binary with Libraries" for both test targets, we'll add libSharedTestUtils.a
, the static library artifact that results from compiling SharedTestUtils
.
It works!
In order to create a library that depends on app code and is shared between two test targets, you need to:
.a
binaryA static library is only one way to include code in a dependency. I also tried using a Unit Test Bundle, but ran into linker issues - the test targets can't link against SharedTestUtils
if it's a Unit Test Bundle, or at least not easily - Xcode doesn't show it in the Link Binary With Libraries setting.
Using a dynamic library (wrapped in a Framework target) is also an option, and it might work well if your utils library just needs XCTest
and doesn't need to @testable import
your app. However, this is subject to the same issue as a Unit Test Bundle, where you can't link against the app binary. Thanks to Boris Bรผgling for pointing out that you can get around this with BUNDLE_LOADER
, but it turns out if you specify the loader app correctly you end up with the following:
'-bundle_loader <path/to/ExampleApp> not allowed with '-dynamiclib'
Apparently BUNDLE_LOADER
doesn't work with dynamic libraries, which is why I went with the static library approach in the first place. To be honest, I'm not sure if this is the best way - there could be something I'm totally missing that might allow this to work with a Framework. Please let me know if you have ideas ๐
Hopefully this approach helps folks who want to maintain multiple unit test targets while keeping shared app test utils in common between them. It's worked great in Fluency, but your mileage might vary - if you're interested in discussing this or other Xcode/Swift/iOS related things, you can follow me on Twitter.
I'm Noah, a software developer based in the San Francisco Bay Area. I focus mainly on full stack web and iOS development