SPM migration causes 3x test suite slowdown on hosted unit test target

I recently migrated MyLargeProjectApp from CocoaPods to Swift Package Manager and noticed MyLargeProjectApp Tests suite test run phase went from 15 minutes to 46 minutes on CI — a 3.1x slowdown with no changes to the tests themselves.

MyLargeProjectApp has the following setup:

  • MyLargeProject AppTarget
  • MyLargeProject TestTarget (containing 11,000 tests)

It's a uniform overhead applied to every single test:

Timing bucket CocoaPods SPM
< 0.1s 86.6% of tests 0.2% of tests
0.1–0.5s 11.0% 94.0%

The way the dependencies are linked with SPM is by having an a top-level umbrella SPM target AppApplication with all the dependencies for the app and test target. MyLargeProject AppTarget and MyLargeProject TestTarget both link AppApplication under Frameworks, Libraries, and Embedded Content.
The (MyLargeApp TestTarget ) is a hosted unit test that runs inside the app process.

import PackageDescription

// MARK: - Targets

let mainTargets: [Target] = [
    .target(name: "Core"),
    .target(name: "Networking", dependencies: ["Core"]),
    .target(name: "UserData", dependencies: ["Core"]),
    .target(name: "FeatureA", dependencies: ["Networking", "UserData"]),
    .target(name: "FeatureB", dependencies: ["FeatureA"]),
    .target(
        name: "TestSupport",
        dependencies: [
            "Core",
            .product(name: "Quick", package: "Quick"),
            .product(name: "Nimble", package: "Nimble"),
        ]
    ),
    // + 100 more
    // Umbrella target — aggregates all feature targets for the main app
    .target(
        name: "AppApplication",
        dependencies: ["Core", "Networking", "UserData", "FeatureA", "FeatureB", "TestSupport",  /* + 100 more */]
    ),
]

// MARK: - Dynamic Framework Settings

enum Settings {
    static let dynamicFrameworkTargets: [String] = ["AppApplication", "Core", "Networking", /*+ 9 more*/]
}

let package = Package(
    name: "AppModules",
    platforms: [.iOS(.v16)],
    products: mainTargets.map { target in
        Settings.dynamicFrameworkTargets.contains(target.name)
            ? .library(name: target.name, type: .dynamic, targets: [target.name])
            : .library(name: target.name, targets: [target.name])
    },
    dependencies: [
        .package(url: "https://github.com/quick/Quick", exact: "7.6.2"),
        .package(url: "https://github.com/quick/Nimble", exact: "14.0.0"),
    ],
    targets: mainTargets
)

AppApplication is declared as a dynamic framework in Package.swift.

Questions

  1. Is the per-test-class time increase due to dyld overhead?
  2. What could be causing this increase?
  3. Is CocoaPods somehow avoiding this overhead?
  4. How can I get the test time down

What I've Tried

  • Removing AppApplication from test target: Link-time failure with ~100 undefined symbol errors

Have you determined whether it's the build or test run phase which is contributing the time increase? I'd start by analyzing that

It's the test run phase

Turns out it was an issue with how our tests were accessing some local jsons to mock network responses. We has some custom bundle setup logic that would recursively look for the jsons.

On CocoaPods:
Bundle(for: Somefile.self) returned a small test host binary bundle, rootPath: nil caused the recursive lookup to walk a few hundred files
Bundle(for: SomeTestSupportBundle.self)SomeUI.framework (small betting module), rootPath: nil caused the recursive lookup to walk 50 files

On SPM with static linking:
Bundle(for: Somefile.self)SomeApp.app (every class in one binary), rootPath: nil → walk entire app
Bundle(for: SomeTestSupportBundle.self) → also Some.app (same reason), rootPath: nil → walk entire app again

So the difference in how cocoapod vs spm was packaging the files caused the problem.