Swift 6.1 adds test scoping to Swift Testing: a custom trait that runs code around a test or a
whole suite. Pair it with a @TaskLocal dependency container and one annotation swaps the app's
environment for mocks.
Setup without scoping repeats itself
Swift Testing uses init and deinit on a class suite for setup and teardown. Every suite that
needs the same ModelContainer or seed data repeats that initializer.
class ModelTests {
let container: ModelContainer
init() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(for: User.self, configurations: config)
}
}
A TaskLocal environment to swap
Hold dependencies in one struct and expose the live instance as a @TaskLocal with the
production value as the default. Task locals can be overridden for the duration of one closure,
which is exactly the shape a test scope needs.
struct Environment {
let search: (String) async throws -> [String]
}
extension Environment {
@TaskLocal static var current = Environment.production
}
The scoping trait
Conform to TestTrait, SuiteTrait, and TestScoping. The single requirement,
provideScope, receives the test's performing function; you must call it or the test never
runs. Wrap the call in the task-local override.
struct MockEnvironmentTrait: TestTrait, SuiteTrait, TestScoping {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
try await Environment.$current.withValue(Environment.mock) {
try await function()
}
}
}
extension Trait where Self == MockEnvironmentTrait {
static var mockedEnvironment: Self { Self() }
}
Apply per test or per suite
@Test(.mockedEnvironment) func verifySomething() async throws {
Environment.current // the mocked environment
}
@Suite(.mockedEnvironment) struct ExamplesTests { /* every test mocked */ }