Home / Blog / Swift Testing: five concrete wins after moving off XCTest

Swift Testing: five concrete wins after moving off XCTest

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 […]

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.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch