Custom Test Behavior

The Testing framework gained a new protocol in Swift 6.1 called TestScoping.

A TestTrait that conforms to TestScoping is able to specify code that runs before and after the annotated test or suite. It’s like 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
}
  1. Add a TaskLocal variable. This is Structured Concurrency version of the thread-local values.

    struct DatabaseContext {
        ...
        @TaskLocal static var current: DatabaseContext?
    }

  2. Create a TestTrait conforming to TestScoping. This protocol has only one function, intended to set task local values when the test runs. The parameter function 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() }
    }

  3. 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:

  1. The TestScoping protocol with a required provideScope method that wraps test execution
  2. A scopeProvider method on the Trait protocol that returns an optional TestScoping instance
  3. 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.