Architecture • 9 August 2026 • Written by Lochan Chugh
Custom Traits in Swift Testing: Decorating Test Execution
Custom Traits in Swift Testing: Decorating Test Execution
For a long time, XCTest was the standard framework for writing tests on Apple platforms. Managing common test preconditions—like stubbing API clients, configuring temporary local databases, or setting up mocking contexts—required writing boilerplate setup and teardown logic. You had to subclass base test cases and override lifecycle methods, which often led to fragile test configurations and shared state leaks.
The new Swift Testing framework replaces lifecycle inheritance with a modular, composable Traits system. By conforming your custom structures to the TestTrait, SuiteTrait, and TestScoping protocols, you can intercept and decorate how tests are run.
Intercepting Test Lifecycles with TestScoping
A custom trait that conforms to TestScoping acts as a wrapper around the test execution. This allows you to run setup logic, execute the test function itself, and perform cleanup inside a structured block. It’s particularly useful for binding Task-Local configurations that must be isolated to individual test runs.
import Testing
import Foundation
// Custom trait to manage database sandboxes per test
struct TempDatabaseTrait: TestTrait, SuiteTrait, TestScoping {
let filename: String
// Developer Thoughts: provideScope wraps the test run.
// The actual test code executes when we call the `function` closure.
// Cleanup can be placed safely inside defer blocks.
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws {
// Setup: Create a temporary directory path
let tempPath = try initializeDatabasePath(named: filename)
defer {
// Teardown: Clean up the file after the test finishes
try? FileManager.default.removeItem(atPath: tempPath)
}
// Pass the path context to the test using a Task-Local variable
try await DatabaseContext.$activePath.withValue(tempPath) {
try await function() // Run the test function
}
}
}
To make the trait easy to use, extend the static properties of Trait:
extension Trait where Self == TempDatabaseTrait {
static func useTempDatabase(named name: String) -> Self {
TempDatabaseTrait(filename: name)
}
}
Applying Scoped Traits to Suites
Once defined, you can apply your custom trait directly to individual @Test functions or to entire @Suite containers:
// Applying the trait to the entire suite. Every test inside gets its own isolated sandbox.
@Suite(.useTempDatabase(named: "Transactions"))
struct AccountTests {
@Test
func verifyDeposit() async throws {
// Developer Thoughts: The database path is bound transparently here.
// We do not need to call setup functions manually.
let path = DatabaseContext.activePath
let db = try Database(at: path)
try db.save(deposit: 150)
#expect(db.balance == 150)
}
}
Summary
The traits system in Swift Testing makes setup and teardown logic modular and reusable. By combining custom traits with Task-Local variables, you avoid the shared state leaks common in XCTest. Keep in mind that since TestScoping runs within structured concurrency, any asynchronous operations inside your tests must be properly awaited. Unstructured background tasks can escape the scope and run after the cleanup phase has completed.