justin․searls․co

I made Xcode's tests 60 times faster

Time is our most precious resource, as both humans and programmers.

An 8-hour workday contains 480 minutes. Out of the box, running a new iOS app's test suite from the terminal using xcodebuild test takes over 25 seconds on my M4 MacBook Pro. After extracting my application code into a Swift package—such that the application project itself contains virtually no code at all—running swift test against the same test suite now takes as little as 0.4 seconds. That's over 60 times faster.

Given 480 minutes, that's the difference between having a theoretical upper bound of 1152 potential actions per day and having 72,000.

If that number doesn't immediately mean anything to you, you're not alone. I've been harping on the importance of tightening this particular feedback loop my entire career. If you want to see the same point made with more charts and zeal, here's me saying the same shit a decade ago:

And yes, it's true that if you run tests through the Xcode GUI it's faster, but (1) that's no way to live, (2) it's still pretty damn slow, and (3) in a world where Claude Code exists and I want to constrain its shenanigans by running my tests in a hook, a 25-second turnaround time from the CLI is unacceptably slow.

Anyway, here's how I did it, so you can too.

The Starting Point

Here's what you get out-of-the-box after creating a new app with the latest OS 26 betas:

  • A freshly-generated iOS 26 app (SwiftUI + SwiftData)
  • A handful of boilerplate code listings and tests that the wizard creates
  • Running tests via xcodebuild takes ~25 seconds minimum on my machine (add the UI tests and it runs well over a minute)
  • Most of that time was Xcode performing code signing, spinning up simulators and doing... whatever Xcode does. Hell, it takes 1-3 seconds for the process to exit after it prints **TEST SUCCEEDED** for some fucking reason
  • Want to pick up some speed by running a single Swift Testing function from the CLI? Well, if you want to do that you'll have to read this post to understand why that's impossible and then monkey-patch Apple's test runner
  • Think you'll be clever and run swift test against your source code instead of xcodebuild? Well, you can't. It'll just bark at you that the test subcommand is only for Swift packages and not apps

This adds up to a situation in which test-driven development infeasible and using Claude Code unworkable. And this is a Hello World app! It's only going to get slower from here.

So—after consulting with some real iOS developers like Blake McAnally to confirm this isn't a really stupid idea—I went to work extracting the application code into a Swift package, for no other reason than to enable faster tests and better command-line ergonomics with swift test.

The Future State

It's frustrating as hell that something this stupid actually works:

  1. Create a Swift package as a framework for all your app's business logic, views, dependencies, everything
  2. Keep your app target as a thin shim that depends on the package as a framework and calls out to the root view from its entry point
  3. Test the package with swift test
  4. Test UI interactions with xcodebuild, presumably less often

This way, instead of firing up a simulator to run headless tests of Swift code, we're only doing so when we actually want to run a full build that includes UI tests.

Here's what the architecture now looks like:

MyApp.app (thin shell)
  └── imports MyAppCore framework
      ├── Models (SwiftData)
      ├── Views (SwiftUI)
      ├── Business Logic
      └── Tests (Swift Testing)

I'm brand new to modern iOS development, so please tell me if I'm off my rocker here, but as far as I can tell, all this solution really does is introduce a minor Swift package configuration shim between the application project and the code itself, allowing it to be compiled and tested outside the context of my slow and girthy Xcode build.

The Implementation

Okay, now for some more detailed instructions you can follow along at home.

Step 1: Create the framework as a Swift Package

First, create a Swift Package alongside the Xcode project:

// MyAppCore/Package.swift
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
let package = Package(
  name: "MyAppCore",
  platforms: [.iOS(.v26), .macOS(.v26)],
  products: [
    .library(name: "MyAppCore", targets: ["MyAppCore"])
  ],
  targets: [
    .target(name: "MyAppCore"),
    .testTarget(name: "MyAppCoreTests", dependencies: ["MyAppCore"])
  ]
)

Step 2: Connect the framework to the app

If you don't already have a workspace, you'll need to create one to use both your app and framework together:

  1. Create a workspace file (if needed):
<!-- MyApp.xcworkspace/contents.xcworkspacedata -->
<?xml version="1.0" encoding="UTF-8"?>
<Workspace version = "1.0">
   <FileRef location = "group:MyApp.xcodeproj">
   </FileRef>
   <FileRef location = "group:MyAppCore">
   </FileRef>
</Workspace>
  1. In Xcode, open the workspace (not the project!)
  2. Add the framework as a dependency:
    • Select the MyApp target → "General" → "Frameworks, Libraries, and Embedded Content"
    • Click "+" and add MyAppCore

Now your app knows it depends on your "core" framework, so any builds will build it as well.

Step 3: Move everything to the framework

Move all your code and (non-UI) test listings from the app target to the newly-created Swift package

Before:

MyApp/
├── MyAppApp.swift
├── ContentView.swift
├── ModelContainerFactory.swift
├── Item.swift
└── Assets.xcassets/

After:

MyApp/
├── MyAppApp.swift # Now just a thin shell
└── Assets.xcassets/ # Resources stay in app
MyAppCore/
├── Package.swift
└── Sources/
    └── MyAppCore/
        ├── ContentView.swift
        ├── ModelContainerFactory.swift
        ├── Item.swift
        └── MyAppRootView.swift

Only the APIs your app actually references need to be made public. Internal types stay internal, and your tests can access them with @testable import.

Step 4: Expose the root view from the framework

The framework exposes a simple root view that the app can inject the SwiftData model container into and then attach to the window group:

public struct MyAppRootView: View {
  private let modelContainer: ModelContainer

  public init(modelContainer: ModelContainer) {
    self.modelContainer = modelContainer
  }

  public var body: some View {
    ContentView()
      .modelContainer(modelContainer)
  }
}

Step 5: Slim Down the App

The app target is now an empty shell. All that's left is to add the package's root view to the application window group:

import SwiftUI
import MyAppCore

@main
struct MyAppApp: App {
  let modelContainer: ModelContainer

  init() {
    do {
      modelContainer = try ModelContainerFactory.createContainer()
    } catch {
      fatalError("Could not create ModelContainer: \(error)")
    }
  }

  var body: some Scene {
    WindowGroup {
      MyAppRootView(modelContainer: modelContainer)
    }
  }
}

Step 6: Run your tests

If you fancy a single script/test file, you could do something like this to give yourself a convenient way to run just your unit tests:

#!/bin/sh
case "$1" in
  unit)
    # script/test unit
    shift
    cd MyAppCore && swift test "$@"
    ;;
  ui)
    # script/test ui
    shift
    xcodebuild -scheme "MyApp-UITests" test "$@"
    ;;
  *)
    # script/test
    # Run everything - unit tests first (fast), then UI tests
    "$0" unit && "$0" ui
    ;;
esac

Want fast feedback? Just run script/test unit. Want to run a single test? That's just script/test unit --filter testFunctionName

Step 7: Enjoy better output

Because Swift Testing is a modern tool, its output is actually reasonable. Meanwhile, xcodebuild barfs out an endless stream of gcc-lookin', PTSD-triggering nonsense.

Running swift test for a fresh Xcode project outputs this:

$ swift test
Running framework unit tests with swift test...
Building for debugging...
[1/1] Write swift-version-39B54973F684ADAB.txt
Build complete! (0.07s)
Test Suite 'All tests' started at 2025-07-25 20:15:48.726.
Test Suite 'All tests' passed at 2025-07-25 20:15:48.727.
         Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.001) seconds
◇ Test run started.
↳ Testing Library Version: 1082
↳ Target Platform: arm64e-apple-macos14.0
◇ Test itemPersistence() started.
◇ Test configurationCreatesModelContainer() started.
◇ Test contentViewInitializes() started.
◇ Test rootViewInitializesWithContainer() started.
◇ Test frameworkInitializes() started.
◇ Test rootViewInitializesWithConfiguration() started.
◇ Test itemCreation() started.
✔ Test frameworkInitializes() passed after 0.001 seconds.
✔ Test itemCreation() passed after 0.001 seconds.
✔ Test configurationCreatesModelContainer() passed after 0.013 seconds.
✔ Test rootViewInitializesWithContainer() passed after 0.013 seconds.
✔ Test rootViewInitializesWithConfiguration() passed after 0.014 seconds.
✔ Test contentViewInitializes() passed after 0.014 seconds.
✔ Test itemPersistence() passed after 0.014 seconds.
✔ Test run with 7 tests in 0 suites passed after 0.014 seconds.

Not perfect, but at least comprehensible. Which is more than can be said of xcodebuild.

What's the catch?

When will this bite me in the ass? We'll find out. I'm told this will complicate static resource handling (e.g. images) a bit, and some Xcode features could get grumpy, but overall it sounds like there are a lot of teams doing essentially the same thing—they're just not necessarily blogging through it like I am.

Testing tools have never been Apple's forte, so all we can do is hope Apple leans into this transition and elevates swift test to the default way to run non-UI application tests before too long. Hopefully they also have plans to introduce a new project build tool for modern Swift apps that discards the legacy xcodebuild cruft.

What about you? Got your own hot Apple development tips? Let's hear'em.


Got a taste for hot, fresh takes?

Then you're in luck, because you'll pay $0 for my 2¢ when you subscribe to my work, whether via RSS or your favorite social network.

I also have a monthly newsletter where I write high-tempo, thought-provoking essays about life, in case that's more your speed:

And if you'd rather give your eyes a rest and your ears a workout, might I suggest my long-form solo podcast, Breaking Change? Odd are, you haven't heard anything quite like it.