Apple's New Swift Testing – Updated 2025
I didn’t think I needed another testing framework. Turns out it takes ten minutes to learn and genuinely reduces boilerplate. Twelve months later, the API has matured – here are the additions you shouldn’t miss.
What’s new in June 2025
confirmation(...)
named and documented – macro‑powered replacement forXCTestExpectation
.Test.current
– runtime access to the executing test’s metadata..bug(id:title:)
trait – link a test to your issue tracker.CustomTestStringConvertible
– customise failure output for your types.- Parallelism is the default – use
.serialized
only as a temporary escape hatch. - Improved tag UI in Xcode 16: navigator grouping, CLI filters, and insights.
- Official cookbook for parameterised tests and a fix for the duplicate‑arguments crash (Swift 6.1).
The rest of this article folds those updates into the original tour.
Assertions
Swift Testing keeps the API surface tiny. Three macros cover 99% of daily work:
#expect(expr) // soft check
#expect(throws: Type.self) { try call() }
let value = try #require(optional) // hard precondition
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.
Tags & Bug links
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() {/*...*/}
}
Filter from CLI: swift test --filter .fast
or --skip .flaky
. In Xcode, toggle the tag view in the Test navigator (⌘‑6 ➜ tag icon).
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
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(...)
confirmation(...)
replaces XCTestExpectation
with a single, composable helper. As with XCTest, you may add async and/or throws to the signature of a test function.
import Testing
// testing async calls
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
#expect(cookies.count == 10)
}
// testing completion handlers
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await withCheckedThrowingContinuation { continuation in
eat(cookies, with: .milk) { result, error in
if let result {
continuation.resume(returning: result)
} else if let error {
continuation.resume(throwing: error)
}
}
}
}
// zero-count guard: assert that nothing happens
@Test("Logout does not sync")
func testLogout() async {
await confirmation("sync engine triggered", expectedCount: 0) { confirm in
sut.logout()
// calling confirm() would fail the test
}
}
If you need to wait for expectations use this
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
}
await Task.yield()
}
if !fulfilled {
Issue.record("Expectation not fulfilled within \(timeout) seconds: \(description)")
}
}
}
Compatibility with XCTest
You can mix XCTest and Testing in the same target. Migrating is easy: drop every XCTest reference and add the Testing annotations we saw in this document.
Running the tests
Run a single tag, beautified:
swift test --filter .network | xcbeautify
Common pitfalls (2025 edition)
⚠️ 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 temporarily. |
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.
If you run from Xcode you are fine, but if you want to run from the terminal you need this:
platforms: [
.iOS(.v13),
.macOS(.v10_15)
],
And avoid importing UIKit in your unit tests with #if canImport(UIKit) … #endif.
You can explicitly add swift-testing as dependency. Because it contains macros you will have to enable the macro clicking on the build error in the Issue Navigator of Xcode.
.package(url: "git@github.com:swiftlang/swift-testing.git", revision: "swift-6.1-RELEASE")
...
.product(name: "Testing", package: "swift-testing")
If you want to run swift-testing for iOS from the terminal you will have to generate a project using Tuist and use xcodebuild.
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/