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:
- New tests in Swift Testing from day one.
- Convert a file when touching it for another reason.
- Convert the parameterised candidates first — anything that is currently five near-identical methods or a loop. That is where the immediate payoff is.
- Leave UI tests alone.
XCUIApplicationis 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.