On which thread do tests run in Swift Testing, and how do you test code isolated to the main actor?
Predict first
Where do plain sync, plain async, @MainActor and MainActor.run code execute in Swift Testing?
// === Package.swift ===
// swift-tools-version:6.0
import PackageDescription
let package = Package(name: "Lib", targets: [.target(name: "Lib"), .testTarget(name: "LibTests", dependencies: ["Lib"])])
// === Sources/Lib/Lib.swift ===
public func square(_ x: Int) -> Int { x * x }
// === Tests/LibTests/T.swift ===
import Testing
import Foundation
@testable import Lib
func onMain() -> Bool { pthread_main_np() != 0 }
@Test func syncPlain() { print("SYNC main=\(onMain())") }
@Test func asyncPlain() async { print("ASYNC main=\(onMain())") }
@MainActor @Test func annotated() { print("ANNOTATED main=\(onMain())") }
@Test func hop() async { let m = await MainActor.run { onMain() }; print("HOP main=\(m)") }
- Plain tests off the main thread; @MainActor and MainActor.run on it
- Everything on the main thread
- Nothing runs on the main thread
Mental model. The main actor is the only desk that may touch the shop window. A test that needs the window must either work at that desk (@MainActor) or send a note to it (MainActor.run).
In Swift Testing, a plain test function is not run on the main thread: both a synchronous and an async plain test reported they were off the main thread in the check, whereas a test marked @MainActor ran on it. That differs from what people expect from “my test is just code in my app”, and it matters because UI-bound types (@MainActor view models, UIKit and SwiftUI objects) can only be used from the main actor. For code under test that is main-actor-isolated, mark the test (or the suite) @MainActor, and the body can use those types synchronously. Alternatively keep the test nonisolated and hop with await MainActor.run { … } for the pieces that need it (the “HOP” line in the check), which is clumsier but explicit.
XCTest differs: its methods are run on the main thread by default for synchronous tests, and an @MainActor class is the way to make isolation explicit there too. Two practical rules follow. Mark the whole suite @MainActor if it mostly tests UI-facing models, since it avoids repeating await, and keep pure logic suites nonisolated so they stay parallel (main-actor tests share one thread and run one at a time). And do not use Thread.isMainThread in async contexts as a check, as the compiler flags it; assert on isolation instead (for example MainActor.assertIsolated()) or by the type’s annotation.
Only main-actor-isolated code is guaranteed the main thread.
Common traps
- Testing a
@MainActortype from a nonisolated test with synchronous calls. It does not compile in Swift 6 mode; annotate the test orawaitthe calls. - Marking every test
@MainActor. They then run one after another and lose parallelism.
They might follow up with
- How do I assert that code ran on the main actor?
MainActor.assertIsolated()inside the code, or test that the annotated API is only callable withawait. - Does this change for UI tests? No; UI tests drive a separate process through accessibility.
Related questions
- 22.9.3 Why does a hand-written spy fail to compile in Swift 6 when a protocol is Sendable, and how do you write…
- 22.9.1 In what order do XCTest’s class and instance setup, teardown blocks and tearDown run, and what does…
- 22.3.1 How do you test async code: async test functions, expectations, confirmations, actors and the main actor?
- 22.6.1 How do you test SwiftUI: view models, hosting a view, ImageRenderer snapshots and ViewInspector?
← Why does a hand-written spy fail to compile in Swift 6 when a…