Preventing a Parallel-Suite Race in Swift Testing Using a Custom Trait
A flaky test traced to two Swift Testing suites drawing into the same UIWindow, because suites run in parallel and every await yields the main actor. The fix is a suite-scoped async lock implemented as a TestScoping trait.
One of my test suites was flaky: it failed on roughly one run in ten, with no change to the code and no clear pattern. The cause was a race between two test suites that both rendered into the test host's single UIWindow. This post explains why Swift Testing allowed that and how a suite-scoped async lock fixed it.
Background: hosted unit tests
Most of my tests exercise view models directly. They are fast, but they never instantiate a real view, so they miss a class of bugs that only appears once SwiftUI and UIKit are involved: a TextField bound to the wrong value, or a control that never reaches the accessibility tree.
A small number of tests cover that gap. They attach a real UIWindow to the test host's scene, install the actual root view controller, and interact with real UIControl instances. These are not UI tests in a separate target; they are @MainActor unit tests that run in process against the real screens.
A representative example hosts the form, types into the real text field, and asserts that the input reached the view model:
swift@Suite("Client form focus", .serialized, .hostedWindows) @MainActor struct ClientFormFocusTests { @Test("the name field is on screen and typing into it reaches the view model") func typingReachesTheViewModel() async throws { let typed = Recorder() let host = UIHostingController(rootView: FormHarness(state: .init(mode: .create)) { action in if case let .fieldChanged(field, text) = action { typed.record(field, text) } }) let window = try await showing(host) // attach the controller to a window on the host scene defer { window.isHidden = true } let field = try #require(await textField(in: window), "the form draws no text field") field.insertText("Emma") window.layoutIfNeeded() #expect(typed.last?.field == .name) #expect(typed.last?.text == "Emma") } }
showing(host) puts the hosting controller on a window attached to the host's scene, and textField(in:) walks the view hierarchy to find the real UITextField. Both run on the main actor and await while the view settles. Those await points are what make the race in the next section possible.
The constraint that matters here is that the test host has exactly one scene, and every one of these suites needs it.
The failure
Swift Testing runs suites in parallel by default. The .serialized trait does not change that: it orders the tests within a single suite and says nothing about two suites running concurrently.
Each hosted test also contains await points. It suspends briefly while the accessibility tree is populated. On a simulator or device, an await is a suspension point at which the main actor can be handed to other work — in this case, a test in a different suite.
The result was the following sequence:
ShellHostedTestsinstalled its root view controller on the host window.- At an
await, the main actor was handed toClientFormFocusTests, which attached its own window to the same scene. - Control returned to
ShellHostedTests, which queried the window and found no tab bar, because the other suite now owned the scene.
Two cartoon test suites argue over a single cracked phone: "the scene's mine" / "no, it's mine". Both render into the host's one UIWindow, so the tab bar is gone when a test looks.
Which suites collided depended on scheduling, which is why the failure correlated with machine load and run order rather than with any specific test. The screens were correct; two tests were contending for one window.
As a general rule, a test that fails only under load or only when the full suite runs points to shared mutable state before it points to a defect in the code under test. A single scene, a singleton, or a shared temporary directory are all candidates.
The fix: serialize access to the scene
The required invariant is narrow: while one window-opening suite runs, no other window-opening suite may start. That is mutual exclusion around the entire suite, including its suspension points.
A conventional lock does not work here. These tests run on the main actor, so blocking the main actor to serialize main-actor work deadlocks immediately. The lock must suspend the caller and return the main actor while a suite waits. An actor with a FIFO queue of continuations provides this:
swiftprivate actor HostedWindowsLock { static let shared = HostedWindowsLock() private var held = false private var waiting: [CheckedContinuation<Void, Never>] = [] func acquire() async { if !held { held = true return } await withCheckedContinuation { waiting.append($0) } } func release() { if waiting.isEmpty { held = false } else { waiting.removeFirst().resume() } } }
The lock should wrap the suite rather than each test. Swift Testing supports this through a custom trait that conforms to TestScoping. Its provideScope(for:testCase:performing:) method runs around the suite's work, which is the correct place to acquire the lock and release it, including on the throwing path:
swiftstruct HostedWindowsTrait: SuiteTrait, TestScoping { let isRecursive = false func provideScope( for _: Test, testCase _: Test.Case?, performing function: @Sendable () async throws -> Void ) async throws { await HostedWindowsLock.shared.acquire() do { try await function() } catch { await HostedWindowsLock.shared.release() throw error } await HostedWindowsLock.shared.release() } } extension Trait where Self == HostedWindowsTrait { static var hostedWindows: Self { HostedWindowsTrait() } }
isRecursive = false is required: it applies the trait to the suite once, so a suite does not acquire the lock twice and deadlock against itself.
A suite opts in by adding the trait:
swift@Suite("Client form focus", .serialized, .hostedWindows) @MainActor struct ClientFormFocusTests { ... }
With the lock in place, one suite acquires it, runs to completion through its await points, and releases it. The next suite waits in the FIFO queue and then owns the scene exclusively.
A bouncer labelled ".hostedWindows lock" lets one suite into "the scene" while the other waits behind a velvet rope in FIFO order. Suite B gets in only after Suite A leaves, so the scene is never shared.
Enforcing the invariant
The lock only works if every window-opening suite applies the trait, and that is easy to forget when adding a test later. A test enforces the rule:
swift@Test("every test file that opens a window on the host's scene takes the hostedWindows lock") func everyWindowOpeningSuiteTakesTheLock() throws { let directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let opensWindow = ["UIWindow(windowScene", "makeKeyAndVisible", "hostWindow()"] // read every test file, collect any that open a window without `.hostedWindows` ... #expect(unlocked.isEmpty, "opens a window without `.hostedWindows`: \(unlocked)") }
It reads the test source files, identifies any that open a window, and fails if such a suite did not apply the trait. A missing trait leaves the suite green locally but turns this test red and names the file to fix.
A related flake
The same tests contained a second, more common race. A retap test located the Customers tab button, slept for 50 ms to let the view settle, and then tapped it. The shell rebuilds its tab bar during that settle, so the button captured before the sleep could be detached from the window by the time it was tapped, and the action had no effect.
The fix is to locate the button and tap it without an intervening sleep, and to confirm it is still attached to the window:
swiftprivate func waitForTabButton(for tab: AppTab, in window: UIWindow) async throws -> UIControl? { let deadline = ContinuousClock.now + .seconds(15) while ContinuousClock.now < deadline { window.layoutIfNeeded() if let button = tabButton(for: tab, in: window), button.window === window { return button } try await Task.sleep(for: .milliseconds(20)) } return nil }
The button.window === window check is the relevant part. A control that has been replaced still exists as an object but is no longer in the window, so its membership must be verified before it is used.
Takeaways
.serialized applies within a suite only. When parallel suites share a resource, that resource needs explicit mutual exclusion, and on the main actor the mechanism must be an async lock rather than a blocking one.
A flaky test that correlates with machine load is a race rather than noise, and the shared resource is often smaller and more obvious than expected. In this case it was a single window that the entire test host was contending for.