I’ve been using Swift Testing on two projects. After living with the differences from XCTest, it’s my default on new work. Here’s the concrete case for it.
1. Parametrized tests that actually work
In XCTest, parametrizing meant either juggling XCTExpectFailure or throwing asserts inside a loop. The failure message never made it obvious which input had broken.
@Test(arguments: [
("user@example.com", true),
("invalid", false),
("a@b.co", true),
("@missing.com", false)
])
func emailValidation(input: String, expected: Bool) {
#expect(isValidEmail(input) == expected)
}The Xcode test navigator lists each case as its own test. You don’t need to dig through a stack trace to see which input blew up.
2. Grouping tests with tags
For splitting out smoke, integration, and slow tests, XCTest forced test classes or filename conventions. In Swift Testing you just tag:
extension Tag {
@Tag static var smoke: Self
@Tag static var slow: Self
}
@Test(.tags(.smoke))
func homeScreenLoads() { /* ... */ }
@Test(.tags(.slow))
func fullSyncFlow() async throws { /* ... */ }To run only smoke tests in CI, pass the tag filter to xcodebuild. The PR feedback loop went from 12 minutes to three.
3. #expect diffs are readable
XCTAssertEqual would print two Codable structs on one line when they differed. To find the actual field that disagreed you had to drop in print statements.
#expect(actualUser == expectedUser)The failure output shows the differing fields as a proper diff. Nested structs included.
4. Async tests feel native
XCTest’s async story runs through XCTestExpectation and wait(for:timeout:). In Swift Testing you just write async throws:
@Test func fetchesUser() async throws {
let user = try await api.fetchUser(id: 42)
#expect(user.email == "user@example.com")
}For a timeout, use the .timeLimit trait:
@Test(.timeLimit(.seconds(5)))
func longOperation() async throws { /* ... */ }5. Suite isolation and dependency injection
Instead of a test class you can use a struct or actor. Each test run gets a fresh instance, which cuts down on shared-state traps:
@Suite struct UserRepositoryTests {
let db: InMemoryDatabase
let repo: UserRepository
init() {
db = InMemoryDatabase()
repo = UserRepository(db: db)
}
@Test func savesUser() throws {
let user = User(id: 1, email: "x@y.z")
try repo.save(user)
#expect(db.users.count == 1)
}
}Migration cost
XCTest and Swift Testing live side by side in the same target. You don’t have to rush a migration: write new tests in Swift Testing and leave old ones alone until you touch them. Porting 30% of a 400-test project took me two days.
When I stay on XCTest
UI tests haven’t moved to Swift Testing yet, so if you’re using XCUIApplication you’re still on XCTest. If you have to support older Xcode (pre-15) you don’t have the option. Outside of that I don’t see a reason for a new project.
Warning: the Xcode UI can stall
Once a parametrized test passes 50 cases, the test navigator slows down. My approach: pick representative cases instead of parametrizing everything. For a 1000-case fuzz test, put it in a separate target and run it from the CLI.
Last bit of advice
Write the first test of a new Swift project in Swift Testing. After a while the verbose XCTest API feels hard to go back to. That it’s pleasant to write matters: less friction against writing tests is a net win.