Swift Testing runs tests in parallel by default. What does .serialized change, and what does it not fix?
Predict first
Three 400 ms tests in an ordinary suite and three 100 ms tests in a serialized suite. What peak concurrency is observed?
// === 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
final class Gauge: @unchecked Sendable {
private let lock = NSLock(); private var live = 0; private var peak = 0
func enter() { lock.withLock { live += 1; peak = max(peak, live) } }
func leave() { lock.withLock { live -= 1 } }
func read() -> Int { lock.withLock { peak } }
}
let parallelGauge = Gauge(), serialGauge = Gauge()
@Suite("Parallel") struct Parallel {
@Test func a() async throws { parallelGauge.enter(); try await Task.sleep(for: .milliseconds(400)); parallelGauge.leave() }
@Test func b() async throws { parallelGauge.enter(); try await Task.sleep(for: .milliseconds(400)); parallelGauge.leave() }
@Test func c() async throws { parallelGauge.enter(); try await Task.sleep(for: .milliseconds(400)); parallelGauge.leave() }
}
@Suite("Serial", .serialized) struct Serial {
@Test func a() async throws { serialGauge.enter(); try await Task.sleep(for: .milliseconds(100)); serialGauge.leave() }
@Test func b() async throws { serialGauge.enter(); try await Task.sleep(for: .milliseconds(100)); serialGauge.leave() }
@Test func c() async throws { serialGauge.enter(); try await Task.sleep(for: .milliseconds(100)); serialGauge.leave() }
}
@Test func zzReport() async throws {
try await Task.sleep(for: .seconds(2))
print("PEAKS parallel=\(parallelGauge.read()) serial=\(serialGauge.read())")
}
- 3 for the ordinary suite and 1 for the serialized one
- 1 for both
- 3 for both
Mental model. Parallel testing is several cooks in one kitchen. .serialized puts one group on a single hob; if two groups still share the same salt jar, they can still collide.
Swift Testing runs tests concurrently, in no guaranteed order, and each test gets a fresh suite instance. Three tests that each sleep 400 ms were alive at the same time (peak concurrency 3 in the check), whereas the same three in a suite marked .serialized never overlapped (peak 1). --no-parallel on the command line forces one at a time for the whole run (Xcode has a similar test-plan option). Serialisation applies to tests inside the suite (and nested suites); it does not change the order of other suites relative to it, and it does not make a parameterised test’s cases wait for each other in older toolchains unless the trait is on it.
Treat .serialized as a stopgap for shared state (a global, a database file, a singleton), not as a design. The better fixes are to give each test its own state (fresh instance, temporary directory, injected dependencies) and to make shared things safe. Remember that serialising costs wall-clock time, can hide order dependence, and only orders within its suite: a global used by tests in two different suites can still race. Keep timing assertions generous (this check sleeps so the overlap is observable), and never assert on “which test ran first”.
Tests in an ordinary suite overlap; a serialized suite runs one at a time.
Common traps
- Serialising a suite to hide a race on a global. Another suite touching the same global still races.
- Relying on declaration order inside a suite. Even serial tests are not guaranteed to run in source order.
They might follow up with
- How do I find hidden order dependence? Run repeatedly and in parallel; failures that appear only then show shared state.
- Is XCTest parallel too? Only across classes or processes when enabled; methods in a class run one at a time.
Related questions
- 22.8.6 How do you give each test its own environment without globals, using TestScoping and task locals?
- 22.8.1 With @Test(arguments:), how are several argument collections combined, and how is a failure reported?
- 22.2.1 How do XCTest and Swift Testing differ, and when do you still use XCTest?
- 22.7.1 Why do tests flake, how do you find and fix them, and what makes a CI test run hermetic?
← How does confirmation count events in Swift Testing, and what can it… How do you give each test its own environment without globals, using… →