Migrating from XCTest to Swift Testing: What's New in Xcode 16
Swift Testing is Apple's new testing framework introduced in Xcode 16, designed as a modern replacement for XCTest. It uses Swift macros to reduce boilerplate, offers better parameterized testing, and integrates with Swift concurrency natively. You can mix Swift Testing and XCTest in the same project — migration can be incremental.
Key Takeaways
@Testreplacesfunc test— any function marked@Testis a test; no naming convention required.#expectreplacesXCTAssert*— one macro replaces the entire XCTAssert family, with better failure messages.- Parameterized tests are first-class —
@Test(arguments:)replaces manual test duplication with proper parameterized test infrastructure. @Suitegroups tests — replacesXCTestCasesubclasses; plain structs and classes work.- Coexistence is supported — you can run Swift Testing and XCTest side by side in Xcode 16+.
Apple introduced the Swift Testing framework at WWDC 2024 alongside Xcode 16. It's designed to replace XCTest with a framework that takes advantage of Swift's modern features — macros, concurrency, generics — rather than being a wrapper around Objective-C's testing infrastructure.
Migration isn't required immediately — XCTest continues to work — but Swift Testing's features are significant enough that most new test code should be written in it.
What Changes
Test Discovery
XCTest discovers tests by naming convention:
// XCTest
class LoginTests: XCTestCase {
func testValidLogin() { ... } // must start with "test"
func helperFunction() { ... } // NOT a test
}Swift Testing uses the @Test attribute:
// Swift Testing
struct LoginTests {
@Test func validLogin() { ... } // is a test
@Test("Invalid password flow") func invalidPassword() { ... } // custom display name
func helperFunction() { ... } // NOT a test (no @Test)
}Any function can be a test regardless of name. Custom display names improve readability in test results.
Assertions
XCTest has a family of assertion functions:
// XCTest
XCTAssertEqual(result.count, 3)
XCTAssertTrue(user.isActive)
XCTAssertNil(error)
XCTAssertNotNil(value)
XCTAssertThrowsError(try riskyOperation())
XCTAssertGreaterThan(score, 0)Swift Testing uses one macro for all of these:
// Swift Testing
#expect(result.count == 3)
#expect(user.isActive)
#expect(error == nil)
#expect(value != nil)
#expect(throws: SomeError.self) { try riskyOperation() }
#expect(score > 0)#expect evaluates the expression and, on failure, shows the actual values of each sub-expression in the failure message. A failure in #expect(result.count == 3) shows both the actual count and the expected count without any additional formatting.
For errors that should stop the test entirely:
// Swift Testing equivalent of XCTAssert with continueAfterFailure = false
try #require(value != nil)
let unwrapped = try #require(optionalValue) // unwraps and requires non-nilTest Organization
XCTest uses XCTestCase subclasses for grouping:
// XCTest
class UserServiceTests: XCTestCase {
var service: UserService!
override func setUp() {
service = UserService(repository: MockRepository())
}
override func tearDown() {
service = nil
}
}Swift Testing uses @Suite with init for setup:
// Swift Testing
@Suite struct UserServiceTests {
let service: UserService
init() {
service = UserService(repository: MockRepository())
}
// No tearDown — Swift's deinit handles cleanup
}Suites are value types (structs) by default, which means each test gets a fresh instance. No shared mutable state between tests within a suite.
For class-based suites (when you need reference semantics or async setup):
@Suite(.serialized) class DatabaseTests {
var db: TestDatabase!
init() async throws {
db = try await TestDatabase.setup()
}
deinit {
db.teardown()
}
}Parameterized Testing
This is the most significant improvement over XCTest. XCTest has no native parameterized test support — teams use loops or generate test methods programmatically.
// XCTest — manual duplication or loops
func testEmailValidation() {
let validEmails = ["user@example.com", "name+tag@domain.co.uk"]
let invalidEmails = ["notanemail", "@nodomain.com", "no@tld"]
for email in validEmails {
XCTAssertTrue(isValidEmail(email), "Expected \(email) to be valid")
}
for email in invalidEmails {
XCTAssertFalse(isValidEmail(email), "Expected \(email) to be invalid")
}
}// Swift Testing — proper parameterized tests
@Test(arguments: [
("user@example.com", true),
("name+tag@domain.co.uk", true),
("notanemail", false),
("@nodomain.com", false),
("no@tld", false),
])
func emailValidation(email: String, expectedValid: Bool) {
#expect(isValidEmail(email) == expectedValid)
}Each argument combination runs as a separate test in Xcode's test navigator. You can re-run individual failing cases without running all inputs.
For single-argument parameterization:
@Test(arguments: OrderStatus.allCases)
func orderStatusDescription(status: OrderStatus) {
let description = status.displayName
#expect(!description.isEmpty)
#expect(description.count <= 50)
}Async Testing
XCTest requires XCTestExpectation for async testing:
// XCTest async pattern
func testFetchUser() {
let expectation = expectation(description: "fetch user")
service.fetchUser(id: 123) { result in
switch result {
case .success(let user):
XCTAssertEqual(user.id, 123)
case .failure(let error):
XCTFail("Expected success, got \(error)")
}
expectation.fulfill()
}
waitForExpectations(timeout: 5)
}
// XCTest modern async (works in Xcode 13+)
func testFetchUser() async throws {
let user = try await service.fetchUser(id: 123)
XCTAssertEqual(user.id, 123)
}Swift Testing is async by default:
// Swift Testing
@Test func fetchUser() async throws {
let user = try await service.fetchUser(id: 123)
#expect(user.id == 123)
}No special setup needed. @Test functions can be async and throws natively.
Tags and Filtering
Swift Testing introduces test tags for cross-suite organization:
// Define tags
extension Tag {
@Tag static var networking: Self
@Tag static var authentication: Self
@Tag static var slow: Self
}
// Apply tags
@Test(.tags(.networking, .authentication))
func loginWithOAuth() async throws { ... }
@Test(.tags(.slow))
func heavyDatabaseMigration() async throws { ... }Run tests by tag from the command line:
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-only-testing-tags 'networking'Or exclude slow tests in CI:
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-skip-testing-tags 'slow'Migration Strategy
Swift Testing and XCTest coexist in the same test target. You don't need to migrate all at once.
Don't migrate:
- UI tests (XCUITest only supports XCTest)
- Tests that use
XCTestExpectationfor complex timing scenarios - Tests that rely on
XCTAssertThrowsErrorwith complex error inspection
Migrate immediately:
- New test files (write in Swift Testing from the start)
- Simple unit tests with straightforward assertions
- Tests with repeated similar cases (parameterize them)
Migrate when refactoring:
- Existing test classes when you touch them for other reasons
- Test suites where you want parameterization
Common Migration Patterns
XCTAssertEqual → #expect
// Before
XCTAssertEqual(user.name, "Alice")
XCTAssertNotEqual(result, .failure)
XCTAssertGreaterThanOrEqual(count, 1)
// After
#expect(user.name == "Alice")
#expect(result != .failure)
#expect(count >= 1)XCTAssertThrowsError
// Before
XCTAssertThrowsError(try risky()) { error in
XCTAssertEqual(error as? AppError, .unauthorized)
}
// After
#expect(throws: AppError.unauthorized) { try risky() }
// Or capture and inspect:
let error = #expect(throws: AppError.self) { try risky() }
#expect(error.code == 401)setUp/tearDown → init/deinit
// Before
class CartTests: XCTestCase {
var cart: Cart!
var inventory: MockInventory!
override func setUp() {
inventory = MockInventory()
cart = Cart(inventory: inventory)
}
override func tearDown() {
cart = nil
inventory = nil
}
}
// After
@Suite struct CartTests {
let cart: Cart
let inventory: MockInventory
init() {
inventory = MockInventory()
cart = Cart(inventory: inventory)
}
// No tearDown needed — struct instance deallocated after each test
}Running Swift Testing in CI
Swift Testing works with xcodebuild and swift test without additional configuration:
# xcodebuild
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15,OS=18.0'
# Swift Package Manager
swift testTest results appear in the same .xcresult bundle format, so existing CI result parsing works unchanged.