Swift Testing – Updated 7/2025
I didn’t think I needed another testing framework. Turns out it takes ten minutes to learn and genuinely reduces boilerplate. What’s not to like?
Assertions
Swift Testing keeps the API surface tiny. Three macros cover 99% of daily work:
#expect(expression)
#expect(throws: MyError.self) {
// ... code supposed to throw MyError
}
// the following throws if MyError is not thrown
let error = try #require(throws: MyError.self) {
// ... code supposed to throw MyError
}
Tip: Xcode now shows a coloured diff for failed #expect
expressions – no more eyeballing string literals.
import Testing
// test passes if expression returns true
#expect(Color.black.toHex() == "000000")
// test passes if expression throws
#expect(throws: ThemeError.self) {
try convertToHex(Color.black)
}
// test ends early if value is nil. Similar to XCTUnwrap.
_ = try #require(Int("1"))
// another way
do {
_ = try convertToHex(Color.black)
} catch ThemeError.wrongColor {
} catch {
// Do not use #expect(false)
// Use Issue.record(error, "message") or Issue.record("message")
Issue.record(error, "Unexpected error")
}
// test passes if #require fails
withKnownIssue {
_ = try #require(Int("A"))
}
Mind the change:
XCTAssertEquals(a, b)
#expect(a == b)
Natural language assertions are deprecated in favor of more expressive and concise APIs. We see this trend in many features of the language: async/await, trailing closure syntax, property wrappers, result builders, implicit return, keypath expressions, macros, etc.
Apple explicitly commented on this change on the document ‘A New Direction for Testing in Swift’. They favor a concise API that is easy to learn and maintain, without specialized matchers.
Organizing Tests
A test is a function annotated with @Test.
import Testing
// All assertions must be inside a function annotated with @Test
@Test
func colorToHex() throws {
#expect(Color.black.toHex() == "000000")
}
// Test functions can be global or be grouped inside a struct, actor, or class.
struct Colors {
@Test
func colorToHex() throws {
#expect(Color.black.toHex() == "000000")
}
}
Organizing Tests in Suites
Optionally, functions may be grouped in objects and be annotated with @Suite.
import Testing
// Objects containing tests may optionally be labeled
@Suite("A test demonstration")
struct TestSuite {
// ...
}
// Tests run in random parallel order unless they have the trait '.serialized'.
@Suite("A test demonstration", .serialized)
struct TestSuite {
// nested suites will inherit the .serialized argument
@Suite struct TestSuite { ... }
}
Fine print:
- Suites can be any type (struct, enum, class, or actor), but classes must be final. Note that classes may inherit from another, but tests can only run from those that are final.
- A suite object must have an initializer without arguments. It doesn’t matter its kind, whether it is private, async, throwing or not.
- Suites can be nested.
- A separate instance of the object is created to run each test function. This means you can use init/deinit as replacement for XCTest setup/teardown.
Organizing Tests with Tags
Define semantic tags once and reuse. This enhances your ability to group them in different ways in the Xcode test navigator.
import Testing
// To create custom tags extend Apple’s Tag
extension Tag {
@Tag static var caffeinated: Self
@Tag static var chocolatey: Self
}
// then add your tag to suites and/or tests
@Suite(.tags(.caffeinated))
struct OneMoreSuite {
@Test(.tags(.caffeinated, .chocolatey))
func whatever() {/*...*/}
}
In Xcode, toggle the tag view in the Test navigator (⌘‑6 ➜ tag icon). From the CLI use --filter
or --skip
:
# skip flaky tests
swift test --skip .flaky
# run a single tag, beautified
swift test --filter .network | xcbeautify
Runtime metadata & custom diagnostics
Need the current test’s name for logging? Use Test.current
:
log.debug("🏃♂️ Running \(Test.current.name)")
Make domain types readable in failure output:
extension User: CustomTestStringConvertible {
var testDescription: String { "\(name)[id: \(id)]" }
}
Parameterizing functions
Did you ever tested values from an array of data? Now you can declare your intention to do so and let the library record the results. This is accomplished using the @Test arguments trait.
import Testing
// This calls the function three times.
// Note that first enum case passed as argument
// needed the explicit type, the rest were inferred.
@Test(arguments: [Flavor.vanilla, .chocolate, .strawberry])
func doesNotContainNuts1(flavor: Flavor) throws {
try #require(!flavor.containsNuts)
}
// Passing allCases will call with all permutations
// of the possible values for each argument.
@Test(arguments: Flavor.allCases, Dish.allCases)
func doesNotContainNuts2(flavor: Flavor, dish: Dish) throws {
try #require(!flavor.containsNuts)
}
// This makes pairs, then calls with each pair.
@Test(arguments: zip(Flavor.allCases, Dish.allCases))
func doesNotContainNuts2(flavor: Flavor, dish: Dish) throws {
try #require(!flavor.containsNuts)
}
Noteworthy:
- Enums can also be passed as allCases if they support CaseIterable.
- You may also pass Array, Set, OptionSet, Dictionary, and Range.
- Tests can be parameterized with a maximum of two collections.
- Update: as of January 2025 the framework crashes if you pass identical arguments. Apple is aware of the problem.
Declarative async expectations
confirmation is simply a diagnostic marker for test logs — it does not suspend or wait.
It can be used in several ways depending on the syntax.
- As a label that annotates a suspension point:
sut.login() confirmation("waiting for login to complete") await sut.$isLoggedIn.waitUntil { $0 == true } // annotated await
- By itself it indicates that a completion handler has completed.
// Completion handlers use confirmation + a closure with 'confirm' // parameter. Test fails if you don’t call confirm. @Test func bakeCookiesAndEat() async { await confirmation("cookies eaten") { confirm in Cookie.bake(count: 10) { cookies in eat(cookies) { confirm() } } } }
- With expectedCount: 0 it tells the test runner “I expect this block to run zero confirmations.”. expectedCount can also take a range (e.g. 1…3) as parameter.
// asserting no side effects occurred @Test("Logout does not sync") func testLogout() async { await confirmation("sync engine triggered", expectedCount: 0) { confirm in sut.logout() // confirm() must not be called, or test fails } }
If you really need to wait for expectations use this contraption:
import Foundation
import Testing
func waitForExpectation(
timeout: Duration,
description: String,
fileID: String = #fileID,
filePath: String = #filePath,
line: Int = #line,
column: Int = #column,
_ expectation: @escaping () -> Bool
) async {
let startTime = ContinuousClock.now
var fulfilled = false
await confirmation(
"Waiting for expectation: \(description)",
expectedCount: 1,
sourceLocation: SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column)
) { confirm in
while !fulfilled && ContinuousClock.now - startTime < timeout {
if expectation() {
fulfilled = true
confirm()
break
}
// Optional sleep: reduces CPU use during polling but adds delay.
// Use if responsiveness is less critical than avoiding CPU usage.
await Task.sleep(nanoseconds: 10_000_000) // 10 ms
await Task.yield()
}
if !fulfilled {
Issue.record("Expectation not fulfilled within \(timeout) seconds: \(description)")
}
}
}
Exit tests
Source: 0008 Exit tests Implemented 6.2 (Xcode 26)
Assert that code crashes (any failure)
@Test func testRejectsBadInput() async {
await #expect(processExitsWith: .failure) {
precondition(false, "Boom 💥")
}
}
Assert specific exit code
import Darwin // for EX_USAGE etc.
await #expect(processExitsWith: .exitCode(EX_USAGE)) {
exit(EX_USAGE) // or call code that does this
}
Assert specific signal (UNIX)
await #expect(processExitsWith: .signal(SIGABRT)) {
fatalError("Will raise SIGABRT")
}
Positive path: ensure code doesn’t crash
await #expect(processExitsWith: .success) {
print("Everything fine ✅")
// No precondition hit, process exits 0
}
Use #require to throw on mismatch
let result = try await #require(
processExitsWith: .failure,
observing: [\.standardErrorContent]
) {
try someDangerousCall()
}
#expect(result.standardErrorContent.contains("panic!".utf8))
Capture stdout or stderr
// Only the key paths you list get copied back—avoids wasting memory.
let r = await #expect(
processExitsWith: .failure,
observing: [\.standardOutputContent, \.standardErrorContent]
) {
print("debug info")
fatalError("oops")
}
#expect(r.standardOutputContent.contains("debug".utf8))
#expect(r.standardErrorContent.isEmpty == false)
Exit test inside a loop / helper
func assertPrecondition<F: Sendable>(
_ body: @escaping F, file: StaticString = #file, line: UInt = #line
) async where F: () async -> Void {
await #expect(processExitsWith: .failure, performing: body)
}
await assertPrecondition {
precondition(false, "passes if precondition is triggered")
}
Guard against unsupported platforms
#if !SWT_NO_EXIT_TESTS // flag provided by Swift Testing
await #expect(processExitsWith: .failure) { fatalError() }
#else
print("Exit tests unavailable here")
#endif
That’s the core toolkit. In practice you’ll lean on .failure
most of the time; reach for .exitCode(_)
or .signal(_)
only when you truly care about the exact termination reason.
Attachments
An attachment can be recorded once. Trying again causes a runtime error.
0009 Attachments Implemented 6.2 (Xcode 26)
Attach a simple value (e.g. string, bytes):
Attachment.record("Debug info", named: "log.txt")
Attach a custom value that conforms to Encodable:
struct CrashReport: Encodable {
let message: String
}
Attachment.record(CrashReport(message: "boom"), named: "crash.json")
Attach a file or directory from disk:
// Works with files or entire directories!
import Foundation
let url = URL(fileURLWithPath: "/tmp/myfile.txt")
try await Attachment(contentsOf: url, named: "myfile.txt")
Attach after a test failure:
@Test func testFailsWithDebugAttachment() async {
let output = "Actual output here"
// attach before #expect so it's available if the test fails
Attachment.record(output, named: "output.txt")
#expect(output.contains("Expected content"))
}
Attach manually constructed Attachment (optional):
let log = Attachment("debug info", named: "log.txt")
Attachment.record(log)
To collect attachments in a particular folder:
swift test --attachments-path ./artifacts
A cross-import overlay is an automatic mechanism in Swift where a third module is imported implicitly if you import two specific modules together.
For instance, when you import Foundation and Testing you get automatic attachment support for Data, URL, Encodable, and NSSecureCoding.
import Foundation
import Testing
Traits
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
Tests Traits
Optionally, add more traits to your tests:
import Testing
@Test("Custom name") // Custom name
@Test(.bug("IOS‑1234", "Crash on login")) // Related bug report
@Test(.tags(.critical)) // Custom tag
@Test(.enabled(if: Server.isOnline)) // Enabled by runtime condition
@Test(.disabled("Currently broken")) // Disabled
@Test(.timeLimit(.minutes(3))) // Maximum time
@Test @available(macOS 15, *) // Run on specific OS versions
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.
Common pitfalls
Mistake | Fix |
---|---|
Overusing #require |
Use #expect for most checks; reserve #require for preconditions. |
Accidental Cartesian product in parameterised tests | Use zip for 1:1 pairings. |
Assuming shared state between tests | Each test gets a fresh instance; move setup to init() . |
Ignoring default parallelism | Mark legacy suites .serialized . |
Compatibility & requirements
Swift Testing and XCTest can coexist. UI & performance tests remain XCTest‑only. To adopt Swift Testing you need:
- Xcode 16 / Swift 6 toolchain (bundled).
- macOS 14.5+ for Xcode – the code under test can still target older OS releases.
- Add
swift-testing
as an SPM dependency only for non‑Apple platforms.
References
- WWDC 24 – Meet Swift Testing, Go further with Swift Testing
- Apple docs (updated 2025): https://developer.apple.com/documentation/Testing
- GitHub – https://github.com/apple/swift-testing
- Swift forums – https://forums.swift.org/c/related-projects/swift-testing/