Test Traits
What is a trait
A test trait in Swift Testing is a modifier you apply to a test or test suite that customizes its behavior.
Example:
@Test(.disabled("Feature broken"))
@Test(.tags(.network))
@Test(.bug("IOS-1234", "Crashes on launch"))
@Test(.enabled(if: Server.isOnline))
@Test(.timeLimit(.seconds(10)))
Traits are passed to @Test(…) or @Suite(…) and the framework applies them during execution. In the example above the traits are:
.disabled(...)
— disables the test.tags(...)
— groups or filters tests.bug(...)
— links the test to a ticket.enabled(if:)
— conditionally runs the test.timeLimit(...)
— sets a time cap
Trait protocols
Formally, a trait is any type that conforms to the TestTrait protocol. It can:
- Add metadata (name, bug ID, tags)
- Modify execution (disable, enable, retry, set timeouts)
- Provide setup/teardown logic (via TestScoping)
- Interact with runtime conditions (ConditionTrait)
There are three traits protocols
- TestTrait: the base protocol
- TestScoping: extends TestTrait and allows running code before and after a test.
- ConditionTrait: extends TestTrait and is used to conditionally include or skip tests.
protocol TestTrait {}
protocol ConditionTrait: TestTrait {
func evaluate() async throws -> Bool
}
protocol TestScoping: TestTrait {
func provideScope(
for test: Test,
testCase: Test.Case?,
performing function: @Sendable () async throws -> Void
) async throws
}
ConditionTrait
A test trait in Swift Testing is a modifier you apply to a test or test suite that customizes its behavior.
A ConditionTrait
is a kind of test trait in Swift Testing that evaluates to a boolean and is used to decide whether to run a test or not. The following modifiers .enabled(if:)
and .disabled(...)
are condition traits:
@Test(.enabled(if: Server.isOnline))
@Test(.disabled("Currently broken"))
Swift 6.2 added an evaluate() method to ConditionTrait to evaluate the condition outside Test instances:
let skipOnCI = #condition(Skip(if: { isRunningOnCI() }))
if try await skipOnCI.evaluate() {
print("Skipping test setup because we're on CI")
} else {
startExpensiveSetup()
}
You can call evaluate on any ConditionTrait:
Skip(if:)
Run(if:)
- Any custom type conforming to ConditionTrait
TestScoping
TestScoping specifies code that runs before and after the annotated test or suite. It can:
- Set up resources before the test runs
- Use @TaskLocal to inject scoped values
- Clean up after the test
It’s the Structured Concurrency replacement for XCTest setUp
/tearDown
but better:
- A trait can be reused across test suites.
- A trait can be applied to only specific tests.
- There is no global mutable state that may interfere with test parallelization.
- It uses Structured Concurrency features for increased performance and compatibility.
Example
I want my tests to access an instance that determines what database to connect to:
struct DatabaseContext {
let connectionString: String
let isTestEnvironment: Bool
}
Here is how I do it:
- Add a TaskLocal variable –this is the Structured Concurrency version of a thread-local value.
struct DatabaseContext { ... @TaskLocal static var current: DatabaseContext? }
- Create a
TestTrait
conforming toTestScoping
. This protocol has only one function, intended to set task local values when the test runs. The parameterfunction
is in charge of running the test. You can ignore the other parameters.struct DatabaseContextTrait: TestTrait, TestScoping { func provideScope( for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void ) async throws { // this is the instance we are about to set as task local value let testContext = DatabaseContext( connectionString: "memory:test-db", isTestEnvironment: true ) // next line sets the value as task local try await DatabaseContext.$current.withValue(testContext) { // ... this line runs before the test try await function() // this function runs the test itself // ... this line runs after the test } // Warning: the task value is released after the function() // runs so it’s not available for code running after the test } } // Make the trait available as a static property extension Trait where Self == DatabaseContextTrait { static var databaseContext: Self { Self() } }
- Add the trait to a test and read the thread local value.
@Test(.databaseContext) func testDatabaseOperations() async throws { // Access the thread local value guard let context = DatabaseContext.current else { Issue.record("No database context available") return } // ... perform operations with it. // for instance, let’s check its properties #expect(context.isTestEnvironment) #expect(context.connectionString == "memory:test-db") }
Suite Behavior and Test Case Behavior
The proposal intelligently handles how traits are applied at different levels:
// Apply to an entire suite
@Suite(.myTrait)
struct DatabaseTests {
@Test func test1() { ... }
@Test func test2() { ... }
}
// Apply only to a specific test
@Test(.myOtherTrait)
func specificTest() { ... }
By default, traits use different behavior depending on context:
- Suite traits that are non-recursive provide their scope once for the entire suite
- Suite traits that are recursive provide their scope once for each test function
- Test function traits provide their scope once for each test case (useful for parameterized tests)
If you plan to apply a trait to an entire suite and each test in that suite, mark the trait as recursive
. Otherwise, it might only run once for the whole suite. This simple oversight can cause unexpected test behaviors.
Implementation Details
Under the hood, this feature works through:
- The
TestScoping
protocol with a requiredprovideScope
method that wraps test execution - A
scopeProvider
method on theTrait
protocol that returns an optionalTestScoping
instance - Default implementations that handle most common cases automatically
This design cleverly avoids creating unnecessarily deep call stacks when multiple traits are applied to the same test, as only traits that actually need custom behavior participate in the execution chain.
Compared to XCTest
In short, Test Scoping Traits let you customize test behavior using Swift concurrency, without resorting to global state. It’s another step toward more composable, precise, and inherently safe testing in Swift. For more information, see the testing proposal SWT-0007 Test Scoping Traits.