Notes on XCTest

Originally published on this website in late 2025 as a blog post.

XCTest is the Apple-backed framework for UI tests. I have used it for Interchange, where is birb, and OpenAppLock. These are some takeaways from my experience.

What is testable?

On iOS, UI testing is different from unit testing. With unit tests, you test directly against internal APIs defined within the app, like models, view models, and other objects along the way.

With UI tests, there is almost no access to an app’s internals. Tests run in an isolated “runner” application which is installed alongside your app. This makes sense if you are a believer in the testing pyramid. However, it also complicates the setup.

In this example for where is birb, the home screen makes a network call to get a list of friends. When unit testing this screen’s view model, you might use URLSession.stubbed(…) to inject a session which returns a custom response. For UI tests, it’s impossible to change this state as the test runner is far removed from the internal app state. How do you check that the friends list renders correctly if you don’t tell it what to render?

The approach I took to resolve this was to run a real backend server, such that I can tell the server what it should respond with. Either have the real backend server running alongside the app, or have a server where the test runner can simulate responses. where is birb uses the first approach; Interchange uses the latter.1

Diagram showing how the server is structured with the test runner and main app. The test runner makes calls to the server to arrange the test, while the app makes server calls like normal.

Social sign-in

where is birb only offers social sign-in with Apple and Google, but I still needed a way to test the app. I avoided testing social sign-in flows entirely, and decided to create a UI test-specific flow for for generating a mock user.

This avoids dependency on the social providers, where we would otherwise have to use, say, a Google test account. That is more trouble than it’s worth.

Clipboard permissions

Both Interchange and where is birb make use of the pasteboard, whether it’s copying a share link or the address of a parking lot. It’s easy to fall into the trap of directly checking the user’s pasteboard in the test runner:

let contents = UIPasteboard.general.string

However, this will lock up the test. If the simulator is open, there might be a dialog asking for pasteboard permission. My first instinct was to use addUIInterruptionMonitor. This did not resolve the issue, because the dialog is caused by the test runner itself.

Instead, for both apps I pasted whatever string I copied into the Spotlight application, asserting the existence of the string there.

When implementing universal links, it makes sense to test that it works in a UI test, right? XCTest actually has a dedicated method to open any link in its default app:

XCUIDevice.shared.system.open(url)

However, the Apple app-domain association file can take several seconds to download after the simulator boots. Not having this file downloaded can cause the link to open in Safari instead.

CI/CD

This has been the biggest hurdle for me. Not only are Macs expensive (they’re required for building iOS apps), but choice of hardware can impact how the tests run.2

Historically, I’ve used GitHub Actions for CI/CD when it came to running Xcode-based tests. GitHub Actions is free for public repositories, which is the case for OpenAppLock. (Thank you GitHub for the free compute.)

Now, I technically use two CI/CD providers: GitHub Actions and Xcode Cloud.

An Apple developer subscription comes with 25 hours of Xcode Cloud per month. So far, I’ve only been using Xcode Cloud for building OpenAppLock releases. However, the CI runners there seem to be much faster than GitHub’s offering, making it a better deal for private repositories.

Something I dislike about Xcode Cloud is that it doesn’t play well with Gitea. Apple seems to have no plan to support this, seeing as this Apple Developer forum thread has remained stale. This is a shame. For my specific situation, Xcode Cloud seems like a decent way to fully replace the macOS runners on GitHub Actions if I move away from GitHub in the future.

There’s still more to learn

Even with its flaws, I prefer the control of XCTest over other solutions like Appium and Maestro, and also like not having a third-party dependency. I’ll probably write another one of these as I encounter more blockers/use cases.

  1. OpenAppLock does not depend on a server, but testing with Screen Time has its own challenges. Maybe I’ll write more on this later. 

  2. I originally wrote this note in late 2025, and now, in 2026, Macs are even more expensive and in short supply.