How do you give each test its own environment without globals, using TestScoping and task locals?

22.8.6 · L5 · Interview

Predict first

Three parallel tests read a task-local region: one in a scoped suite, one with its own scope, one with none. What do they print?

// === 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

enum Env { @TaskLocal static var region = "global" }

struct InRegion: TestTrait, SuiteTrait, TestScoping {
    let region: String
    var isRecursive: Bool { true }
    func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws {
        try await Env.$region.withValue(region) { try await function() }
    }
}
@Suite(InRegion(region: "EU")) struct Scoped {
    @Test func sees() async throws { try await Task.sleep(for: .milliseconds(50)); print("SCOPED", Env.region) }
    @Test(InRegion(region: "US")) func overrides() async throws { try await Task.sleep(for: .milliseconds(50)); print("OVERRIDE", Env.region) }
}
@Test func outside() async throws { try await Task.sleep(for: .milliseconds(50)); print("OUTSIDE", Env.region) }
  • EU, US and global respectively
  • All print global
  • All print EU

Mental model. Each test gets its own lanyard badge on entry. Anything it does inside the building reads the badge, and no one else’s badge is visible.

A trait that conforms to TestScoping (together with TestTrait and/or SuiteTrait) implements provideScope(for:testCase:performing:), which wraps the execution of each test: you set something up, call performing, and tear it down. Combined with a @TaskLocal value, this gives per-test environment without a shared mutable global: the value is bound only inside the scope, travels to child tasks, and parallel tests cannot see each other’s values. In the check, a suite scoped to region “EU” sees EU, one test overrides it with “US” (the innermost trait wins), and a test outside any scope sees the default “global”, all running in parallel and sleeping so they overlap.

This is the Swift Testing way to do what setUp and tearDown did for ambient state: dependency containers, a fixed clock or locale, a temporary database. Use isRecursive to apply a suite trait to every test in it rather than once around the suite. It requires a toolchain where TestScoping exists (Swift 6.1 and later; I ran Swift 6.4), so older Xcode versions need the init/deinit fallback. Task locals are the key: a plain static var would be shared by all parallel tests, which is exactly the bug scoping avoids. Beware that work started with Task.detached or on a raw thread does not inherit task locals.

The innermost scope binds the task local for that test only.

Common traps

  • Setting a global in provideScope instead of a task local. Parallel tests overwrite each other.
  • Expecting Task.detached to see the scoped value. Detached tasks do not inherit task locals.

They might follow up with

  • Why not init and deinit? They work for per-instance state, but cannot wrap the test body or bind task-local values.
  • What is the older alternative? Pass dependencies explicitly and use a fresh instance per test.

Related questions

  • 22.8.5 Swift Testing runs tests in parallel by default. What does .serialized change, and what does it not fix?
  • 22.8.2 What are traits in Swift Testing, and how do you skip, tag, bound and annotate tests with them?
  • 22.4.2 What makes a test non-deterministic, and how do you control time zones, locales, randomness, UUIDs and the…
  • 22.2.1 How do XCTest and Swift Testing differ, and when do you still use XCTest?

← Swift Testing runs tests in parallel by default. What does…