Home

Moving a test suite from XCTest to Swift Testing

Swift Testing is a genuine improvement over XCTest rather than a rename. The migration is incremental — both frameworks run in the same target — and the parts that got better are worth the afternoon.

The mapping

// XCTest
final class ValidatorTests: XCTestCase {
    func testValidEmailPasses() {
        XCTAssertTrue(Validator.isValidEmail("a@b.com"))
    }

    func testInvalidEmailFails() throws {
        let result = try Validator.check("nope")
        XCTAssertEqual(result, .invalid)
    }
}

// Swift Testing
struct ValidatorTests {
    @Test func validEmailPasses() {
        #expect(Validator.isValidEmail("a@b.com"))
    }

    @Test func invalidEmailFails() throws {
        let result = try Validator.check("nope")
        #expect(result == .invalid)
    }
}
XCTest Swift Testing
XCTAssertTrue(x) #expect(x)
XCTAssertEqual(a, b) #expect(a == b)
XCTAssertNil(x) #expect(x == nil)
XCTUnwrap(x) try #require(x)
XCTFail("…") Issue.record("…")
XCTAssertThrowsError #expect(throws: MyError.self) { … }
setUp() init()
tearDown() deinit
XCTestExpectation await confirmation { … }

Two things follow from #expect taking an ordinary expression: there is one assertion macro instead of forty, and the failure message shows the values — #expect(count == 3) fails with “Expectation failed: (count → 5) == 3”, which XCTest could not do.

#expect versus #require

#expect records a failure and continues. #require throws and stops the test.

@Test func loadsProfile() async throws {
    let profile = try #require(await api.profile(for: 42))   // stop if nil
    #expect(profile.name == "Tuan")                          // record and continue
    #expect(profile.email.contains("@"))
}

This is better than XCTest’s model, where a nil unwrap either crashed the whole run or required XCTUnwrap plus a guard. Use #require for preconditions and #expect for the assertions you want all of, even when one fails.

Parameterised tests are the real win

This is what removed a third of my test code:

@Test(arguments: [
    ("a@b.com", true),
    ("no-at-sign", false),
    ("", false),
    ("@nolocal.com", false),
    ("spaces in@email.com", false),
])
func emailValidation(input: String, expected: Bool) {
    #expect(Validator.isValidEmail(input) == expected)
}

Five separate tests, each reported individually, from one function. In XCTest this was either five near-identical methods or a for loop inside one test — and the loop version reports one failure for five cases and stops at the first.

Two arguments produce a cross product:

@Test(arguments: [1, 2, 3], ["a", "b"])
func combinations(number: Int, letter: String) { … }     // six tests

Tip

The reporting is the point. Each case is a separate test in the navigator with its arguments in the name, so a failure tells you which input broke without reading the loop. Xcode also lets you re-run a single failing case.

Traits

Metadata attached to tests, replacing several XCTest conventions:

@Test(.disabled("flaky on CI"))
@Test(.bug("https://github.com/example/issues/42"))
@Test(.timeLimit(.minutes(1)))
@Test(.tags(.networking))
@Suite(.serialized)                    // run this suite's tests in order

.disabled with a reason is much better than commenting a test out or prefixing it with x — the test still compiles, so it does not rot, and the reason is visible in the report.

.serialized matters because Swift Testing runs tests in parallel by default, including within a suite. XCTest ran serially within a class.

The difference that caught me out

That parallelism, combined with the other change: a new instance of the suite type is created for each test.

struct DatabaseTests {
    let database: Database

    init() async throws {
        database = try await Database(inMemory: true)   // runs before EVERY test
    }
}

That is good — it removes shared state between tests, which is where flakiness comes from. But my XCTest suite had accumulated tests that quietly depended on running in order and on a shared XCTestCase instance, and half a dozen of them failed immediately under parallel execution.

Every one turned out to be a real problem: tests that only passed because a previous test had left state behind. Fixing them was the actual value of the migration, and it was not what I was migrating for.

For genuinely shared expensive setup, a static let or an actor works, and .serialized is the escape hatch when a suite really cannot run in parallel.

Migrating incrementally

Both frameworks coexist in one target, so there is no big-bang switch. What worked:

  1. New tests in Swift Testing from day one.
  2. Convert a file when touching it for another reason.
  3. Convert the parameterised candidates first — anything that is currently five near-identical methods or a loop. That is where the immediate payoff is.
  4. Leave UI tests alone. XCUIApplication is still XCTest, and there is no Swift Testing equivalent yet.

One caveat: performance tests. There is no measure { } equivalent, so anything using XCTMetric stays in XCTest for now.