Swift Testing: The Framework Replacing XCTest, and What Stays In XCTest
XCTest, Apple’s testing framework since 2013, is being replaced by Swift Testing. Apple does not say “deprecated” yet (XCTest still ships, still works, still hosts UI automation and performance tests), but every Apple session, every Swift Forum thread, and every new sample project since WWDC 2024 directs Swift code at Swift Testing. The framework ships with Xcode 16+ and Swift 61, runs on macOS, iOS, watchOS, tvOS, visionOS, and Linux2, and presents a different mental model: tests are functions, suites are types, configuration is traits, and parallelism is the default.
The migration is incremental. Apple’s own documentation explicitly supports running both frameworks side-by-side in the same target3, which means the right move for a non-trivial codebase is not “rewrite everything” but “write new tests in Swift Testing and migrate the old ones as you touch them.” The post walks the API surface, names the migration boundary (what stays in XCTest), and covers the traits vocabulary that replaces XCTest’s class-hierarchy configuration.
TL;DR
- Swift Testing replaces XCTest’s class-based, stringly-typed model with macros:
@Test,@Suite,#expect(...), and#require(...)4. - Tests are free functions or methods on types; suites are any type containing test functions (
@Suiteis only required when specifying a display name or trait). - Configuration moves from class hierarchies and naming conventions to traits:
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...)5. - Parametrized tests via
@Test(arguments:)produce one parent test result with one child per argument; parallel by default6. - Migration is side-by-side, not big-bang. UI automation (
XCUIApplication) and performance testing (XCTMetric) stay in XCTest because Swift Testing does not cover them3. - WWDC 2025 added custom attachments (test artifacts) and exit tests (verifying code that’s expected to terminate under specific conditions)7.
Why Swift Testing Exists
XCTest’s pain points are well-known to anyone who maintained a large iOS test suite:
Stringly-typed assertions. XCTAssertEqual(a, b, "message") does not capture the original Swift expression. When the assertion fails, the failure message tells you the runtime values but not which expression produced them. Debugging requires reading the test source and reconstructing context.
Class-based configuration. Setup, teardown, and shared state require subclassing XCTestCase. Disabling a test requires either renaming it (so the discovery pattern misses it) or wrapping it in #if DEBUG. Selecting which tests run requires careful naming.
Concurrency mismatch. XCTest predates Swift Concurrency. XCTestExpectation plus wait(for:timeout:) is the legacy bridge to async code; the pattern is verbose and error-prone. Modern Swift code uses async/await; XCTest’s idioms feel anachronistic.
Weak parametrization. XCTest has no first-class parametrized test support. Developers fake it with for-loops inside test methods (which produces one test result for many cases) or write code-generators (which produces N test methods from a template).
Swift Testing addresses each:
import Testing
@Test func userInitializesWithDefaults() {
let user = User()
#expect(user.name == "Anonymous")
#expect(user.preferences.isEmpty)
}
#expect captures the full expression. When the assertion fails, the test output shows user.name == "Anonymous" failed because user.name was "Guest". The framework does the work of reading the expression because the macro sees the syntax tree. No “expected X, got Y” guessing.
The Macros
Four macros do the work.
@Test and @Suite
@Test marks a function as a test. The function can live at file scope, in a struct, in an actor, in an enum, anywhere a function can live in Swift. The framework discovers @Test-marked functions and runs them.
@Test func loginSucceedsWithValidCredentials() async throws {
let result = try await api.login(username: "alice", password: "valid")
#expect(result.isAuthenticated)
}
struct AuthenticationTests {
@Test func loginRejectsEmptyPassword() async throws {
let result = try await api.login(username: "alice", password: "")
#expect(!result.isAuthenticated)
#expect(result.error == .emptyPassword)
}
}
@Suite marks a type as a test suite explicitly. The marker is implicit when a type contains @Test functions; explicit @Suite is only required when adding a custom display name or applying a trait at the suite level4.
@Suite("Authentication", .tags(.security))
struct AuthenticationTests {
@Test func loginSucceeds() async throws { ... }
@Test func loginFails() async throws { ... }
}
#expect and #require
#expect is the standard assertion. The test continues if the expectation fails (the failure is recorded; subsequent assertions still run). #require is the fail-fast variant: if the requirement fails, the test stops immediately. Use #require for preconditions whose failure would produce nonsense errors in subsequent assertions.
@Test func userPreferencesLoad() async throws {
let user = try #require(await store.fetchUser(id: 42))
// If fetchUser returns nil, the test stops here. The next line
// never runs against an unwrapped optional.
#expect(user.preferences["theme"] == "dark")
#expect(user.preferences.count >= 1)
}
The try in front of #require matters: #require returns the unwrapped value when the requirement is satisfied (in this case the non-optional User) or throws when it’s not. The test function must be throws to use #require for unwrapping.
Parametrized Tests
@Test(arguments:) runs the test function once per argument. The framework reports each argument as a child test with the parent test as the rollup.
@Test(arguments: [
("alice", "password123", true),
("bob", "", false),
("", "password123", false),
("carol", "wrongpassword", false),
])
func loginScenario(username: String, password: String, expectedSuccess: Bool) async throws {
let result = try await api.login(username: username, password: password)
#expect(result.isAuthenticated == expectedSuccess)
}
The framework runs the four arguments in parallel by default. Test failure messages identify which argument tuple failed, so debugging a parametrized failure does not require running the test in isolation.
For arguments that are independent collections, @Test(arguments: arrayA, arrayB) runs the cross-product (every combination). For sequential pairs, use zip(arrayA, arrayB) and pass the resulting sequence.
Traits: Configuration As First-Class Values
XCTest uses class hierarchies, naming conventions, and Xcode test plans to configure tests. Swift Testing uses traits: values applied to @Test and @Suite declarations5:
@Test(.enabled(if: ProcessInfo.processInfo.isRunningOnCI))
func ciOnlyIntegrationTest() async throws { ... }
@Test(.disabled("Pending fix for FB12345"))
func knownFailingTest() { ... }
@Test(.timeLimit(.minutes(1)))
func potentiallySlowTest() async throws { ... }
@Test(.tags(.network, .integration), .bug("FB67890", "regression in retry logic"))
func networkRetryTest() async throws { ... }
@Suite(.serialized)
struct DatabaseTests {
// All tests in this suite run sequentially.
// Useful when tests share a database fixture.
@Test func insertUser() { ... }
@Test func updateUser() { ... }
@Test func deleteUser() { ... }
}
The trait vocabulary covers the cases XCTest required custom infrastructure for:
.enabled(if:)and.disabled(_:). Conditional enablement with a runtime expression and an optional reason. Replaces#if DEBUGblocks and renaming tricks..serialized. Marks a suite or parametrized test as sequential. Inheritable: a serialized suite forces all child tests serial6..timeLimit(...). Per-test time limit. The test is killed and reported as failed if it exceeds the limit..tags(...). Tags for filtering. Configure include/exclude tag rules in an Xcode test plan; the plan runs viaxcodebuild test -testPlan <name>. Note that-only-testing:filters by test identifier (target, suite, or function), not by tag..bug(...). Links a test to a bug ID with a reason. The link surfaces in the test report and (for tests known to be failing) prevents the failure from blocking CI.
Custom traits are also supported. An app can define its own traits for project-specific needs (e.g., .requiresKeychain, .skipIfOffline).
Parallelism Is The Default
Swift Testing runs tests in parallel by default, including parametrized arguments. The default matters because most test suites grow over time, and parallelism is the difference between a 30-second test run and a 5-minute test run. XCTest required explicit opt-in (test parallelism toggle in the test plan); Swift Testing flips the default.
The implication: tests must be independent. Shared state (a database fixture, a mocked file system, an app-level singleton) must be either isolated per test (each test gets a fresh fixture) or marked .serialized (the suite runs sequentially). Tests that mutate global state and assume serial execution are bugs that Swift Testing surfaces faster than XCTest did.
Migration: Side-By-Side, Not Big-Bang
Apple’s official position on migration is incremental3:
- Both frameworks coexist in the same test target.
- New tests can be written in Swift Testing immediately.
- Old XCTest tests continue to work without changes.
- Migrate XCTest tests to Swift Testing when convenient (a touched file, a new feature, a refactor).
Two cases stay in XCTest indefinitely:
UI automation tests. XCUIApplication, XCUIElement, XCUIElementQuery, the entire UI testing API, are XCTest-only3. Swift Testing does not provide a replacement. UI tests stay in XCTest until Apple extends Swift Testing’s coverage.
Performance tests. XCTMetric, measure(metrics:), performance baselines, also XCTest-only3. Swift Testing does not yet have an equivalent.
For everything else (unit tests, integration tests, async tests, parametrized tests), Swift Testing is the recommended default for new code.
What WWDC 2025 Added
Two notable additions to Swift Testing at WWDC 20257:
Custom attachments. A test can attach arbitrary Attachable data to its result for failure triaging. Failed-screenshot images, diagnostic logs, generated input files. The attachments surface in Xcode’s test reporter and in CI artifacts. The Attachment.record(_:named:sourceLocation:) API in Sources/Testing/Attachments/Attachment.swift defines the surface; values conforming to Attachable (Data, String, image types via opt-in conformance) are accepted.
@Test func reportLayoutAtFailure() async throws {
let result = await renderer.run(LayoutTestCase.complex)
if !result.passes {
Attachment.record(result.diagnostics, named: "diagnostics.txt")
Attachment.record(result.screenshotData, named: "failure-screenshot.png")
}
#expect(result.passes)
}
Exit tests. Tests that verify code which is expected to terminate (call exit(), throw a fatal error, trigger a precondition failure). XCTest had no clean way to test these; Swift Testing’s exit tests run the code in a child process and verify the termination behavior.
@Test func parserExitsOnMalformedInput() async {
await #expect(processExitsWith: .failure) {
Parser.parse(malformedInput)
}
}
Both additions are pragmatic. Custom attachments solve a real pain point (debugging flaky CI failures); exit tests cover a genuine gap in the unit-test surface.
When XCTest Is Still The Right Call
Three cases where new code should still be XCTest:
The test is a UI automation test. XCUIApplication.launch, tap-and-swipe interactions, accessibility-identifier-based queries. Swift Testing does not cover the UI automation surface; new UI tests are XCTest.
The test is a performance benchmark. measure(metrics: [XCTClockMetric()]), performance baselines, memory measurements. Swift Testing does not have equivalents; new performance tests are XCTest.
The test must run on a platform that doesn’t support Swift Testing. Swift Testing requires Swift 6 / Xcode 16+. Tests that must compile against earlier toolchains (a CI matrix that includes older Xcode versions) need XCTest until the toolchain catches up.
For unit tests, integration tests, async tests, and parametrized tests on Swift 6+ targets, Swift Testing is the modern default. The migration cost is real but the cost of not migrating compounds; every new XCTest written today is technical debt.
The Agent-Workflow Connection
Tests are the contract that AI-assisted code generation works against. When an agent writes Swift code, the tests are how the developer (or the agent itself) verifies the change is correct. Swift Testing’s properties make agent-generated code more verifiable:
- Expression capture.
#expect(user.score == calculator.compute(user))failure shows both sides of the comparison. An agent reading the failure can fix the bug without re-reading the test file. - Parametrized cases. An agent fixing a regression knows which specific input failed. The fix can be targeted.
- Trait-based filtering. A
.tags(.regression)annotation lets an agent run only regression tests after a fix; faster feedback loop. - Custom attachments. An agent debugging a flaky test can attach the failing-input artifact and read it on the next run.
The post on hooks for Apple development covers Stop hooks that run tests after every Claude Code edit. Swift Testing’s faster, more parallel, more informative failures make that loop tight enough that the agent can iterate without manual intervention.
What This Pattern Means For iOS 26+ Apps
Three takeaways.
-
Default new tests to Swift Testing. The framework is more expressive, runs in parallel by default, captures failure context automatically, and supports parametrized tests as a first-class concept. Existing XCTest code continues to work; new code uses the modern default.
-
Keep UI automation and performance tests in XCTest. Apple’s coverage gap, not a migration choice. Until Swift Testing extends to those domains, those tests stay in XCTest indefinitely.
-
Use traits as the configuration vocabulary.
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...)cover the cases that XCTest required class hierarchies, naming conventions, and#if DEBUGblocks for. Traits are first-class values that compose; the trait vocabulary is what makes the framework feel modern.
The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
Does Swift Testing replace XCTest entirely?
Not yet. XCTest still hosts UI automation tests (XCUIApplication) and performance tests (XCTMetric); Swift Testing does not cover those domains. For unit tests, integration tests, async tests, and parametrized tests, Swift Testing is the recommended default for new code. Both frameworks coexist in the same target without conflict.
What’s the practical difference between #expect and XCTAssertEqual?
#expect captures the full Swift expression and reports both sides of the comparison on failure (e.g., user.score == calculator.compute(user) failed because user.score = 80 and calculator.compute(user) = 100). XCTAssertEqual knows only the runtime values and the optional message string. The expression capture is what makes Swift Testing failures self-explanatory without re-reading the test source.
How do parametrized tests work?
@Test(arguments: [...]) runs the test function once per argument. The framework reports each argument as a child test result with the parent as the rollup. Tuples can pass multiple values. Parametrized tests run in parallel by default; add .serialized to force sequential execution.
What’s #require for?
#require is the fail-fast variant of #expect. Use it for preconditions whose failure would produce nonsense errors in subsequent assertions. try #require(value) returns the unwrapped value when satisfied or throws when not, so the rest of the test can use the unwrapped value directly. The test function must be throws to use #require for unwrapping.
Can I write Swift Testing tests in Linux?
Yes. Swift Testing is open-source2 and runs on every platform Swift supports, including Linux. Server-side Swift projects can adopt Swift Testing immediately. The framework is not Apple-platform-specific.
How do I migrate an existing XCTest suite?
Apple’s recommendation is incremental: write new tests in Swift Testing, migrate old ones when you touch them3. There is no automated converter (the model differences are large enough that automation would be brittle). The right migration target is unit tests and integration tests; UI tests and performance tests stay in XCTest indefinitely.
References
-
Apple Developer: Swift Testing. Apple’s overview of Swift Testing’s adoption with Xcode 16+ and Swift 6. ↩
-
Open-source repository: swiftlang/swift-testing on GitHub. The framework’s source, available under the Apache License 2.0 with Swift Project’s standard license. ↩↩
-
Apple Developer Documentation: Migrating a test from XCTest. Apple’s official migration guide covering side-by-side coexistence and the XCUIApplication / XCTMetric exceptions. ↩↩↩↩↩↩
-
Apple Developer Documentation: Swift Testing. Framework reference for
@Test,@Suite,#expect,#require, and the trait vocabulary. ↩↩ -
Apple Developer Documentation: Trait. The protocol that defines test traits, with built-in traits including
.enabled(if:),.disabled(_:),.serialized,.timeLimit(...),.tags(...),.bug(...). ↩↩ -
Apple Developer: Meet Swift Testing (WWDC 2024 session 10179) and Go further with Swift Testing (WWDC 2024 session 10195). The introduction sessions covering parametrized tests and parallelism. ↩↩
-
Open-source repository evidence for WWDC 2025 additions:
Sources/Testing/Attachments/Attachment.swiftfor custom attachments andSources/Testing/ExitTests/for the#expect(processExitsWith:)exit-test macro. Both first surfaced at WWDC 2025 and ship with Xcode 26+. ↩↩