XCTest vs Swift Testing: The Modern Approach

XCTest vs Swift Testing: The Modern Approach
Apple introduced Swift Testing at WWDC 2024, and it's a game-changer for how we write tests. Let's see why you should consider migrating.
Follow along with the code: iOS-Practice on GitHub
The Old Way: XCTest
Here's what XCTest looks like—you've probably written this a hundred times:
import XCTest
@testable import MyApp
class WeatherTests: XCTestCase {
var mockService: MockWeatherService!
var sut: WeatherManager!
override func setUp() {
super.setUp()
mockService = MockWeatherService()
sut = WeatherManager(service: mockService)
}
override func tearDown() {
sut = nil
mockService = nil
super.tearDown()
}
func testFetchReturnsData() async throws {
let weather = try await sut.getWeather(for: "London")
XCTAssertEqual(weather.city, "London")
}
}
Classes, inheritance, implicitly unwrapped optionals, setup/teardown boilerplate. It works, but it's verbose.
The New Way: Swift Testing
Same test, new framework:
import Testing
@testable import MyApp
struct WeatherTests {
let mockService = MockWeatherService()
let sut: WeatherManager
init() {
sut = WeatherManager(service: mockService)
}
@Test func fetchReturnsData() async throws {
let weather = try await sut.getWeather(for: "London")
#expect(weather.city == "London")
}
}
No inheritance. No optionals. No teardown. Just a struct with an initializer.
Assertions: Natural Swift Expressions
XCTest has dozens of assertion functions:
// XCTest
XCTAssertEqual(weather.temp, 72)
XCTAssertTrue(isLoaded)
XCTAssertNil(error)
XCTAssertNotNil(result)
XCTAssertGreaterThan(count, 0)
Swift Testing uses #expect with standard Swift expressions:
// Swift Testing
#expect(weather.temp == 72)
#expect(isLoaded)
#expect(error == nil)
#expect(result != nil)
#expect(count > 0)
You don't need to remember XCTAssertGreaterThanOrEqual—just write #expect(count >= 0).
Error Testing: Finally Clean
Testing that code throws an error in XCTest is awkward:
// XCTest
func testThrowsCityNotFound() async {
do {
_ = try await sut.getWeather(for: "FakeCity")
XCTFail("Expected error")
} catch {
XCTAssertEqual(error as? WeatherError, .cityNotFound)
}
}
Swift Testing handles this elegantly:
// Swift Testing
@Test func throwsCityNotFound() async {
await #expect(throws: WeatherError.cityNotFound) {
try await sut.getWeather(for: "FakeCity")
}
}
One line instead of do/catch/XCTFail boilerplate.
Quick Reference
| XCTest | Swift Testing |
|---|---|
import XCTest | import Testing |
class Foo: XCTestCase | struct Foo |
func testBar() | @Test func bar() |
XCTAssertEqual(a, b) | #expect(a == b) |
XCTAssertTrue(x) | #expect(x) |
XCTFail("msg") | Issue.record("msg") |
throw XCTSkip() | throw Skip("reason") |
setUp() | init() |
Can You Mix Them?
Yes! You can use both frameworks in the same project. This makes gradual migration practical—write new tests with Swift Testing while keeping existing XCTest tests working.
Interview tip: "Swift Testing lets me write tests faster with less boilerplate. I use #expect with natural Swift expressions instead of memorizing XCTAssert variants, and init() instead of setUp/tearDown lifecycle methods."