I have been writing all my new tests with Swift Testing for a few weeks now, and I am not going back. It ships inside the Swift 6 toolchain and Xcode 16, it is open source, and it runs on Apple platforms, Linux and Windows. But the part that matters for me is more practical: the tests are shorter, the failures are easier to read, and the framework finally understands async/await without ceremony. 🚀
This is not a “XCTest is dead” post. XCTest is not going anywhere, and you will still need it for some things (more on that at the end). This is just what I learned moving real tests from one to the other, with the code to back it up.
The first thing you notice: macros instead of conventions
XCTest finds your tests by convention. You subclass XCTestCase, you name your methods testSomething(), and the runner picks them up. Swift Testing uses macros instead: @Test on any function (any name you want), and @Suite to group them.
// XCTest
import XCTest
final class CalculatorTests: XCTestCase {
func testAddition() {
XCTAssertEqual(2 + 2, 4)
}
}
// Swift Testing
import Testing
@Suite("Calculator")
struct CalculatorTests {
@Test("Adds two numbers")
func addition() {
#expect(2 + 2 == 4)
}
}
Small things, but they add up: no test prefix forced on every method, a human-readable name as the first argument (this is what shows in the Test Navigator), and your suite can be a struct instead of a class. That last one is more important than it looks, and I will come back to it. 😄
One macro to replace the whole XCTAssert* family
This is my favorite change. XCTest has dozens of assertion functions: XCTAssertEqual, XCTAssertTrue, XCTAssertNil, XCTAssertGreaterThan, and so on. Swift Testing has basically two macros: #expect and #require.
#expect takes a normal Swift boolean expression. You write the comparison you already know how to write:
#expect(user.name == "Ada")
#expect(items.count > 0)
#expect(response.statusCode == 200)
#expect(!cart.isEmpty)
The macro captures the sub-expressions, so when it fails the output shows the actual values, not a generic “XCTAssertEqual failed”. If user.name was "Bob", the failure tells you that. No more adding a message string just to know what the values were. 💡
Here is the rough mapping if you are coming from XCTest:
| XCTest | Swift Testing |
|---|---|
XCTAssertTrue(x) | #expect(x) |
XCTAssertFalse(x) | #expect(!x) |
XCTAssertEqual(x, y) | #expect(x == y) |
XCTAssertNil(x) | #expect(x == nil) |
XCTAssertGreaterThan(x, y) | #expect(x > y) |
try XCTUnwrap(x) | try #require(x) |
XCTFail("…") | Issue.record("…") |
The difference between the two macros: #expect is a soft check, it records the failure and the test keeps running. #require is a hard check, it throws and stops the test right there. And #require also unwraps optionals (it is the XCTUnwrap replacement), returning the value for you to use:
@Test func unwrapAndCheck() throws {
let user: User? = repository.first
// Stops here if nil, otherwise gives me the unwrapped value
let validUser = try #require(user)
#expect(validUser.name == "Ada")
}
One thing I hit: XCTAssertEqual(x, y, accuracy:) for floating point has no built-in equivalent. Apple’s migration guide points you to isApproximatelyEqual() from swift-numerics. Worth knowing if you test a lot of math.
Parameterized tests (the feature I wanted for years)
XCTest has no native way to run the same test with different inputs. You end up writing for loops inside one test, and when case number 7 fails you do not know which one it was without extra prints.
Swift Testing gives you @Test(arguments:). Each argument runs as a separate test in the navigator, they run in parallel, and a failure in one does not stop the others:
@Test("All foods are available", arguments: [Food.burger, .iceCream, .burrito])
func foodAvailable(_ food: Food) async throws {
let truck = FoodTruck(selling: food)
#expect(await truck.cook(food))
}
If your type is CaseIterable, you can cover every case automatically, so new enum cases get tested without touching the test:
@Test(arguments: Food.allCases)
func foodAvailable(_ food: Food) async throws { ... }
The one I use the most is pairing inputs with expected outputs using zip. This is the classic “table of cases” pattern, and it reads really clean:
@Test(arguments: zip(
["[email protected]", "invalid", ""],
[true, false, false]
))
func emailValidation(email: String, expected: Bool) {
#expect(EmailValidator.isValid(email) == expected)
}
A heads up: if you pass two separate collections (not zipped), Swift Testing runs the Cartesian product, every combination. So arguments: Food.allCases, 1...100 with 5 foods is 500 runs, not 5. Use zip when you want them paired element by element.
Suites are structs, and that gives you isolation for free
This is the design decision I appreciated the most after a while. In XCTest, your XCTestCase is a class (reference type) and tests share setup through setUp / tearDown. State leaking between tests is a classic source of flaky suites.
In Swift Testing, your suite is usually a struct, and the framework creates a fresh instance for every single test. So state isolation is automatic. init replaces setUp, and it runs fresh before each test:
@Suite("Food truck tests")
struct FoodTruckTests {
let truck: FoodTruck
init() async throws { // runs fresh per test, like setUp
truck = FoodTruck()
truck.batteryLevel = 100
}
@Test func engineStarts() {
#expect(truck.startEngine())
}
@Suite struct GrillTests { // suites can nest
@Test func ignites() { ... }
}
}
If you need deinit-style teardown, you have to use a class (or actor), because deinit only exists on reference types. But for most of my tests the struct + init combo is enough, and I stopped worrying about one test polluting another. ✨
Traits: conditions, tags, time limits, ticket links
Traits are extra arguments you pass to @Test or @Suite to control how and when a test runs. This replaces a pile of XCTest patterns (XCTSkipIf, XCTSkipUnless, runtime guards):
@Test(
"Ice cream is cold",
.enabled(if: Season.current == .summer), // run only when condition is true
.disabled("We ran out of sprinkles"), // skip with a reason
.bug(id: "12345"), // link to a tracker ticket
.timeLimit(.minutes(1)), // fail if it runs too long
.tags(.legallyRequired) // categorize
)
func isCold() async throws { ... }
Tags are not loose strings, you declare them with the @Tag macro and then filter by them in the Test Navigator:
extension Tag {
@Tag static var legallyRequired: Self
}
@Test("License is valid", .tags(.legallyRequired))
func licenseValid() { ... }
One limitation to know: .timeLimit only takes minute granularity, you cannot set a sub-minute limit with the built-in trait. Not a big deal for most cases, but it surprised me the first time.
Async and parallel-by-default (read this part carefully)
If you tested async code in XCTest you know the dance: create an XCTestExpectation, fulfill it in a callback, call wait(for:timeout:). Swift Testing just lets you mark the test async and await directly.
// XCTest
func testSoldFood() {
let exp = expectation(description: "sold food")
FoodTruck.shared.eventHandler = { if case .soldFood = $0 { exp.fulfill() } }
wait(for: [exp], timeout: 1.0)
}
// Swift Testing
@Test func soldFood() async {
await confirmation("Sold food") { soldFood in
FoodTruck.shared.eventHandler = { if case .soldFood = $0 { soldFood() } }
await FoodTruck.operate()
}
}
For callback/event code where you cannot just await, confirmation() replaces expectations, and it takes a count range: expectedCount: 0 means “must not happen”, 1... means “at least once”, and so on.
Now the part that actually bit me: Swift Testing runs your tests in parallel by default, in-process. XCTest runs serially inside a test class. So when I moved an old suite over, a couple of tests started failing because they were quietly depending on execution order and shared state. That was not a Swift Testing bug, it was a latent bug in my tests that XCTest’s serial execution was hiding. 🤔
If you need serial execution (shared resource, order matters), opt out with .serialized:
@Suite(.serialized)
struct RefrigeratorTests {
@Test(arguments: Condiment.allCases)
func refill(condiment: Condiment) { ... } // one at a time now
}
And if a test touches the main thread / UI state, pin it with @MainActor. My advice: do not blindly add .serialized everywhere to make failures go away. Half the time the parallel run is telling you about a real isolation problem you want to fix.
Testing for errors
throws tests are native, a test marked throws fails if an error escapes, so you stop wrapping every call. To assert that something does throw, use #expect(throws:):
// XCTest
XCTAssertThrowsError(try order.add(topping: .mozzarella, toPizzasIn: -1..<0))
XCTAssertNoThrow(try order.add(topping: .caper, toPizzasIn: 0..<1))
// Swift Testing
@Test func toppingErrors() throws {
// A specific error value
#expect(throws: PizzaToppings.Error.outOfRange) {
try order.add(topping: .mozzarella, toPizzasIn: -1..<0)
}
// Must NOT throw
#expect(throws: Never.self) {
try order.add(topping: .caper, toPizzasIn: 0..<1)
}
// Capture the error and inspect it
let error = #expect(throws: PizzaToppings.InvalidToppingError.self) {
try Pizza.current.add(topping: .marshmallows)
}
#expect(error?.topping == .marshmallows)
}
#expect(throws:) returns the caught error, so you can assert on associated values. That alone is nicer than the XCTest version where inspecting the error meant a closure dance.
What still needs XCTest
Here is the honest part, because Swift Testing does not cover everything yet. As of now, you still reach for XCTest when you need:
- UI tests with
XCUIApplication/XCUIElement. Swift Testing does not do UI automation. - Performance tests with
measure { }and theXCTMetricfamily. No equivalent in Swift Testing. - Floating point
accuracy:comparisons (use swift-numerics, as mentioned above).
The good news: both frameworks live in the same test target. SwiftPM (swift test) and Xcode 16 run both and merge the results. So you do not migrate everything in one weekend. You write new tests in Swift Testing, convert old ones when you touch them, and keep your XCUIApplication UI tests exactly where they are. The only rule is do not mix #expect and XCTAssert* inside the same test function. 😅
My takeaway
For unit and logic tests, Swift Testing is my default now. The macros are less code, the failures read better, async is finally first-class, and the struct-per-test isolation removed a whole category of flaky bugs from my suites. The parallel-by-default behavior is the one thing to watch, but for me it surfaced real problems instead of creating fake ones.
If you are on Xcode 16 / Swift 6, my suggestion is simple: write your next test file with import Testing and see how it feels. You do not have to migrate anything to start. The official migration guide has the full mapping table when you are ready to convert the old ones.