Apple introduced Swift Testing at WWDC 2024 as a modern replacement for the company’s XCTest offering. Internal benchmarks showed that Swift Testing can run tests from four to seven times faster than XCTest, mainly because it defaults to running them in parallel for both synchronous and asynchronous cases. Swift Testing also provides richer failure messages with its #expect macro, which speeds debugging by inspecting an expression and reporting detailed information when a failure occurs.
Slow, mostly serialized XCTest runs had clogged DoorDash’s iOS continuous integration, or CI, pipelines. As our apps and shared packages grew, the CI test step stretched into double-digit minutes, consumed more CI resources, and slowed iteration for every engineer.
Consequently, we drafted a rollout plan to adopt Swift Testing and measure CI improvements with an eye toward eventually making it the default for all new tests. That plan also sparked a key idea: We could automate most of these XCTest to Swift Testing migrations instead of doing everything manually.
With leadership support, we started migrating the shared iOS codebase.
Planning the migration
Before proceeding, we aligned on three simple principles:
- Local ownership: Each module team would migrate its own tests, with shared tooling and guidance from platform engineers.
- Tooling first: We would invest in a repeatable environment so that any engineer could migrate a module in a few guided steps.
- Reliability gates: We wanted to be certain that we weren’t trading speed for flakiness. Every migrated package would need to pass a strict reliability check before shipping.
We mapped which packages to migrate first, outlined the main improvements we expected from Swift Testing, and documented the high-level rollout steps.
Building the migration environment with Cursor, SweetPad, and MCP
Manually porting thousands of tests would be slow and error prone. Instead, we created a migration environment where any iOS engineer could upgrade their own module with help from Cursor and SweetPad.
SweetPad and MCP
SweetPad is a Visual Studio (VS) Code and Cursor extension that lets engineers build, run, test, and debug Apple platform apps using Xcode’s command line tools while staying inside their editor. Xcode is still installed and used under the hood, but most of the day-to-day work happens in VS Code or Cursor.
DoorDash’s iOS platform team extended SweetPad with a model context protocol (MCP) server to let AI tools like Cursor drive builds and tests by calling VS Code commands. The main tool exposed is execute_vscode_command, which takes a commandId such as sweetpad.build.build or sweetpad.build.test and runs that action. The server starts automatically when the SweetPad extension activates and stops when the editor closes.
Cursor connects to this MCP server through a small configuration file. Once connected, an engineer can ask Cursor to:
- Build a target.
- Run tests for a package.
- Re-run tests after a migration rule has changed a file.
This combination gave us a fast feedback loop inside the editor. Engineers did not have to switch to a terminal or to Xcode to verify their changes.

Cursor migration rules
On top of SweetPad and MCP, we defined a Cursor rule to encapsulate the core Swift Testing migration changes into a single configuration. Its job was to handle the mechanical edits for us so that engineers could focus on behavior and reliability.
At a high level, this rule:
- Replaces XCTest with Swift 6’s testing framework, for example import XCTest ➝ import Testing and XCTestCase ➝ struct + @Suite.
- Migrates all XCTest-style APIs to Swift Testing equivalents, including XCTAssert* ➝ #expect, XCTUnwrap ➝ #require, XCTFail ➝ Issue.record, and expectations ➝ confirmations.
- Converts test function signatures to Swift Testing style such as remove test prefix, fix naming, add @Test, and handle throws/async.
- Transforms setUp, subtests, and loops into Swift Testing patterns, including init instead of setUp, nested structs for subtests, and parameterized tests for loops.
- Modernizes async tests, including publishers and completion handlers, to use async iterators, confirmations, and withCheckedContinuation instead of timing hacks.
- Preserves important structure and imports such as keep @testable import, avoid linter-driven changes, and don’t migrate files using assertMacroExpansion).
- Ensures the migration is complete and consistent across the file, with no TODOs or partially converted tests, and no stray empty line at the end.
One version of the rule looked like this:
---
description: "Convert XCTest-based tests to Swift Testing syntax"
globs: *Test*.swift
alwaysApply: false
---
# Usage
Use this rule to migrate `XCTestCase`-based tests to Swift’s new **Testing** framework (Swift 6), or when writing new tests using Swift Testing.
**Purpose:** Standardize tests on Swift Testing so you get faster runs, better failure messages, and async friendly APIs without rewriting everything by hand.
---
## Task
Given a file containing XCTest tests, rewrite it as a Swift Testing test suite.
Before applying these rules, you should be familiar with Swift’s Testing framework and its official docs.
**Purpose:** Make sure the migration is mechanical, not conceptual. The rules assume you already understand how Swift Testing works.
...
Full Version: https://github.com/maatheusgois-dd/bazel-ios-swiftui-template/blob/4e2f7b64d9376fe722bfa5607639a7c39cb57077/.cursor/rules/rule-unittestingrules.mdc
Using Cursor’s user interface, we applied this rule to each test target in the shared codebase. For example, a typical XCTest case like this:
import API
import XCTest
final class APITests: XCTestCase {
func test_success() {
XCTAssertTrue(API.returnTrue())
}
}
Became this:
import API
import Testing
struct APITests {
@Test func success() {
#expect(API.returnTrue())
}
}
For larger or more complex files, engineers could run the rule once, fix any compilation issues, and then re-run tests with SweetPad. If a file had more than a few hundred lines, we suggested that it be split by responsibility first, then migrated and retested.
File-level reliability in SweetPad
For day-to-day work, the first reliability gate lived inside SweetPad. The Cursor migration prompt told engineers to migrate each file to Swift Testing, use MCP to test after the change, and fix any bugs or crashes before moving on.
In practice, after running the migration rule on a file, engineers used SweetPad commands to run that package’s tests multiple times from Cursor. When there was any suspicion of flakiness or timing sensitivity, engineers were told to aim for 10 green runs in the editor before moving on. This repeated execution was a simple but effective way to flush out tests that depended on order or timing.
Because SweetPad forwarded these test runs to our underlying build tools, the behavior in the editor matched what we expected from local command-line runs.
Stay Informed with Weekly Updates
Subscribe to our Engineering blog to get regular updates on all the coolest projects our team is working on
Please enter a valid email address.
Thank you for Subscribing!
Bazel encoded into the migration prompt
Our team began adopting Bazel when we were mid-way through the Swift Testing migration. This was at the point when we began to consider the best way to continue the migration without relying on MCP and SweetPad. Ultimately, our alternative approach required that we create a new methodology for using Bazel. It worked extremely well, allowing agents to easily determine how to construct the commands based on a simplified Bazel command. The same principle applies to teams that use xcodebuild or any other build system: Encode your test command into the migration prompt so the AI agent can run tests directly.
The workflow is the same for every package:
- Run bazel test <package>.
- Read the failures, then fix the tests or address the migration issues.
- Run bazel test <package> again.
- Repeat until there is one clean run.
To make this easy to remember, we encoded Bazel usage directly into the migration instructions for Cursor:
- For each file in a package, after applying the Swift Testing rule, run the tests with Bazel and fix failures before continuing.
- After all files in the package are migrated, run bazel test <package> 10 times as the final gate.
This keeps Bazel at the center of the reliability story. It does not matter whether the tests are triggered from a local terminal or through SweetPad and Cursor. The same targets run, the same ten-run rule applies, and every package is held to the same standard. Figures 2 and 3 show how the same migration rule can be executed through SweetPad and Cursor: first as a package-level instruction, then as an editor-driven code change and Bazel verification loop.


To learn more, you can review an example project in Github here: https://github.com/maatheusgois-dd/bazel-ios-swiftui-template
Migration was only successful if the resulting tests via SweetPad and Bazel were reliable. We treated reliability as a first-class goal, not an afterthought.
Why the migration exposed flaky tests
Most of the flaky tests we found were not new bugs introduced by Swift Testing. They were tests that had quietly depended on the old XCTest execution model.
Under XCTest, tests tended to run in a sequential, process-based way with limited parallelism. But tests run with Swift Testing, including async tests, can run in parallel by default, and the framework is designed to integrate with Swift concurrency.
When tests previously run with XCTest were migrated to Swift Testing, this change in execution model exposed hidden assumptions, for example:
- Relying on a specific test order:
import Testing
var globalCache: [String: String] = [:] // ❌ shared state
struct OrderDependentTests {
@Test func storesValue() {
globalCache["user"] = "Alice"
#expect(globalCache["user"] == "Alice")
}
@Test func cacheStartsEmpty() {
// ❌ FLAKES if storesValue ran first / in parallel
#expect(globalCache.isEmpty)
}
}
- Sharing mutable global state without isolation:
import Testing
var sharedFlags: Set<String> = [] // ❌ shared state
struct GlobalStateTests {
@Test func enablesFlag() {
sharedFlags.insert("featureA")
#expect(sharedFlags.contains("featureA"))
}
@Test func startsWithNoFlags() {
// ❌ FLAKES depending on other tests
#expect(sharedFlags.isEmpty)
}
}
- Assuming work completed synchronously when it actually depended on timing:
import Testing
final class APIClient {
func fetchUser(id: String, completion: @escaping (String?) -> Void) {
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
completion("User-\(id)")
}
}
}
struct TimingAssumptionTests {
@Test func fetchesUser() async {
let client = APIClient()
var result: String?
client.fetchUser(id: "123") { result = $0 }
// ❌ FLAKY: guessing timing
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(result == "User-123")
}
}
The move from XCTest, which was mostly synchronous, to the more async and parallel Swift Testing world is what caused many of the flakes. SweetPad and Bazel gave us tools to find and fix them.
In practice, most engineers use a small shell loop. The following is a Bazel example:
for i in {1..10}; do
echo "=== Run $i/10 - <PackageName> ==="
bazel test //path/to/package:all --test_output=errors || exit 1
done
If any run fails, the loop stops. The team then treats that failure as a sign of flakiness or a missed migration detail, fixes the root cause, and restarts the loop from the beginning.
This ten-run rule is important because Swift Testing runs tests in a more asynchronous and parallel way than our old XCTest suites did. Hidden assumptions became visible when the package was run repeatedly under Bazel; this gave us confidence in the migrated suite’s stability. Figure 4 shows the final verification step: the migrated package is run ten times under Bazel, and each successful pass increases confidence that the Swift Testing version is stable under repeated parallel execution.

Result: Faster CI and happier developers
Our metrics made the impact of migrating to Swift Testing clear; standardizing this workflow showed the value in the day-to-day experience of working on iOS code. Our Swift Testing Eng Win summary highlighted a number of advantages, including:
- Faster test steps in CI: The unit test step in CI became roughly 60% faster. This contributed to an average of about 40% faster overall builds for shared iOS packages. Engineers now get feedback from CI much faster.
- CI cost savings: Faster tests meant fewer credits used per build. At our current number of builds per week, this translates into saving several thousand dollars on CI each year, which can be reinvested in other infrastructure work.
- Developer time reclaimed: By shrinking the time that builds spend on tests, we freed up multiple engineer days per week that had previously been lost to waiting. That time now goes into building features, not watching progress bars.
- Broad adoption with no regressions: We migrated our shared packages to Swift Testing, which is about 90% coverage of shared iOS modules, and did not see regressions in the migrated suites. Every bug that an XCTest used to catch is still caught by Swift Testing. The stricter, more parallel execution model also helped expose latent issues that we then fixed, improving quality.
The net result is that our iOS builds are faster, cheaper, and more pleasant to work with. The richer failure messages help engineers diagnose issues quickly, and writing new async tests feels more natural with Swift Testing APIs.

Lessons learned
Reliability must be a first-class goal
The ten-run rule for both SweetPad and Bazel turned out to be one of the most valuable parts of the process. It aligned everyone on a concrete definition of success:
- A migrated package is not done until it can pass its tests 10 times in a row.
This rule caught flaky tests early, before they could land on the main branch, and pushed us to clean up hidden dependencies on order and timing. We plan to reuse this pattern for other large infrastructure changes.
Swift Testing is powerful but not universal
Swift Testing is a big step forward, but not every existing test is a good candidate for migration. Most tests migrated cleanly. A few needed small adjustments, such as:
- Tests that assumed UIKit calls from background threads, which had to be brought in line with Swift concurrency rules.
- Some parameterized tests that became clearer once they used Swift Testing’s arguments-based style.
We are, however, keeping one case on XCTest: Code that relies on assertMacroExpansion under the hood, which in turn uses XCTFail. Swift Testing can’t handle those call patterns yet. And that highlights an important lesson: Not everything should be migrated. It is acceptable to leave a small number of tests on XCTest while the ecosystem catches up.
Celebrate infrastructure wins
This project reminded us that measuring and communicating infrastructure work matters. By tracking CI time, cost, and the migration progress, and subsequently sharing the results as an engineering win, we kept contributors motivated and gave leadership a clear view into impact. It is easy for platform work to stay invisible. Turning it into a story with concrete outcomes helps build support for the next round of improvements.
Next up
Although our Swift Testing migration is in its final stages. The work has opened up several new opportunities, including:
- Finishing the long tail: A small percentage of tests are still on XCTest, mostly in legacy modules or special cases such as macro expansions. We will continue to migrate what makes sense and document any patterns that remain better suited to XCTest.
- Making Swift Testing the default: We are updating lint rules and documentation so that new tests are written in Swift Testing by default. For example, a SwiftLint rule can flag new subclasses of XCTestCase or new XCTest imports so that developers can be guided toward the new APIs instead.
- Automating metrics and sharing tools: We plan to automate CI metrics collection around cost and time so that we can share our migration scripts and experience with other teams who may face similar upgrades.
- Exploring further improvements: With faster tests in place, we can experiment with running suites more often or testing more pull requests without slowing teams down. We are also watching Swift Testing’s evolution so that we can adopt new capabilities quickly.
The overall goal is the same: Keep improving the iOS developer experience across speed, reliability, and scale.
Join us
At DoorDash, we care deeply about the tools and infrastructure that support our engineers. If you enjoy working on high-impact projects that make large codebases faster and more reliable, we would love to work with you. We are hiring for roles on our mobile, platform, and infrastructure teams. Come help build the systems that power millions of deliveries and the tooling that keeps our engineers productive.
