All posts
Swift Testing iOS Xcode

Swift Testing: The Modern Way to Write iOS Tests

XCTest has served us well since the Objective-C era, but it shows its age. Method names prefixed with test, the rigid setUp/tearDown lifecycle, and verbose XCTAssert* calls are all remnants of a different time. Swift Testing, introduced with Xcode 16, is the framework Apple designed from scratch for modern Swift.

The Basics: @Test and #expect

Instead of naming methods with test prefixes, you annotate them with @Test:

import Testing

@Test func userCanLogin() {
    let auth = AuthService()
    let result = auth.login(email: "test@example.com", password: "secret")
    #expect(result == .success)
}

#expect is a macro that captures the full expression and shows you exactly what failed — no more cryptic XCTAssertEqual failed: ("left") is not equal to ("right").

Suites: Grouping Tests Naturally

Use struct or class with @Suite to group related tests. Properties defined on the suite are freshly initialized for each test — no shared mutable state by default.

@Suite("Authentication")
struct AuthTests {
    let auth = AuthService()  // fresh instance per test

    @Test func loginSucceeds() {
        #expect(auth.login(email: "a@b.com", password: "123") == .success)
    }

    @Test func loginFailsWithWrongPassword() {
        #expect(auth.login(email: "a@b.com", password: "wrong") == .failure(.invalidCredentials))
    }
}

Compare that to XCTest where you had to be careful about setUp resetting shared state — here it’s automatic.

Parameterized Tests

This is one of the biggest wins. Run the same test logic over multiple inputs with @Test(arguments:):

@Test("Validates email format", arguments: [
    "valid@email.com",
    "also.valid+tag@domain.co.uk",
])
func validEmailsPass(email: String) {
    #expect(Validator.isValidEmail(email))
}

@Test("Rejects malformed emails", arguments: [
    "notanemail",
    "@nodomain",
    "missing@.tld",
])
func invalidEmailsFail(email: String) {
    #expect(!Validator.isValidEmail(email))
}

Each argument combination appears as a separate test case in the test navigator. With XCTest you had to write separate methods or use a loop that would stop at the first failure.

Async Tests Without Extra Setup

Swift Testing integrates naturally with Swift Concurrency. No expectation objects, no wait(for:timeout:):

@Test func fetchesUserProfile() async throws {
    let service = UserService()
    let profile = try await service.fetchProfile(id: "123")
    #expect(profile.name == "Claudio")
}

Throwing Tests and Error Checking

Use #require when you need to unwrap optionals or throw immediately if a condition isn’t met:

@Test func parsesJSON() throws {
    let data = try #require(Data(jsonString: "{\"id\": 1}"))
    let item = try JSONDecoder().decode(Item.self, from: data)
    #expect(item.id == 1)
}

#require throws if the condition fails, stopping the test immediately — cleaner than force-unwrapping or calling XCTUnwrap.

XCTest vs Swift Testing: When to Use Which

XCTestSwift Testing
UI Tests❌ (not yet)
Performance tests❌ (not yet)
Parameterized tests
Async testsVerboseNative
Error messagesGenericExpressive

For now, keep XCTest for UI and performance tests. Use Swift Testing for all new unit and integration tests. They coexist fine in the same target.

Getting Started

Swift Testing ships with Xcode 16 and requires no additional dependencies. Just import Testing and start writing. The migration from XCTest is gradual — you don’t need to rewrite existing tests to start using the new framework on new code.

If you’re starting a new feature with a clean test file, there’s no reason not to use Swift Testing today.