Swift Testing vs XCTest: when to migrate and how to start.

You are currently viewing Swift Testing vs XCTest: when to migrate and how to start.

Introduction

Recently my team started the transition to Swift Testing, so I thought it would be a good idea to document the process. The idea is that if other teams are in the same situation and want to migrate but are not sure, I hope this can help them understand what benefits it brings and why it is worth learning.

It is important to clarify that this blog does not cover how to do testing from scratch or all the fundamental concepts of this technique, but it focuses on how to migrate from one technology to another and what are the advantages of the new approach.

What is Swift Testing?

Swift Testing is Apple’s new testing framework, introduced at WWDC24, that modernizes how tests are written in Swift. Compared to XCTest, it offers a more expressive syntax, better integration with Swift language features, clearer error messages, and improved test parameterization and organization.

swift-testing-banner

A quick reminder: what XCTest gives us and its “details”.

XCTest remains an excellent and widely used testing framework. It’s stable, well integrated with Xcode, Xcode Cloud, and SwiftPM, supports UI and performance tests, and has been the standard choice for Swift projects for years.

The problem is more about “style” and ergonomics:

  • The API comes from Objective-C and relies on classes(XCTestCase).
  • Tests are detected by the function name(test) instead of using annotations.
  • There are a thousand variants of asserts(XCTAssertEqual, XCTAssertTrue, etc.) and the code looks noisy.
  • Everything lives in classes, reusing logic is not always so clean.
  • Concurrency doesn’t feel so natural if you already think about async/await.

And that’s where Swift Testing comes in: it doesn’t come to throw everything away, but to polish those frictions and make writing tests in Swift feel more “Swift”.

xctest-vs-swift-testing

When does it make sense to migrate to Swift Testing?

Not every team is in the same situation, so this isn’t about migrating “just because”, but about choosing the right moment. In general, adopting Swift Testing makes sense if you’re already using Xcode 16 and Swift 6 (or planning to upgrade with CI support), starting new modules or large features with many new tests, or when existing XCTest suites feel heavy—full of XCTestCase boilerplate, duplicated tests that could be parameterized, and complex setUp/tearDown logic that Swift Testing simplifies with init and deinit. It’s also a good choice when test runtime becomes a concern and you need better parallelization, random execution order, and clearer failure messages.

In some cases, staying on XCTest longer makes sense—especially for projects with stable UI or performance tests, Objective-C test suites, or constraints that prevent upgrading to Xcode 16. A practical approach is to write new Swift tests using Swift Testing while keeping existing XCTest code unchanged. This enables a gradual migration without major refactors or workflow disruptions.

XCTest vs Swift Testing: same ideas, new face

Let’s compare the basics: how you import, how you declare tests, and how you initialize the fixture (what in XCTest we did with setUp/tearDown).  All this is aligned with the official Apple guide Migrating a test from XCTest.

1. Importing the framework

With XCTest

import XCTest

With Swift Testing

import Testing

You don’t need anything else: Swift Testing comes integrated with Xcode 16 / Swift 6, as long as you use it in a test target, not in the app target. 

2. How tests are declared

In XCTest:

  • XCTestCase subclasses.
  • Each test is a function starting with the word test so that it can be recognized as a test.
import XCTest
@testable import Pricing

final class PriceCalculatorTests: XCTestCase {

    func test_finalPrice_appliesDiscount() {
        let result = PriceCalculator.finalPrice(base: 100, discount: 0.2)
        XCTAssertEqual(result, 80.0, accuracy: 0.001)
    }
}

In Swift Testing:

  • You don’t inherit from anything.
  • You use the @Test macro to mark functions as tests.
  • The name does not have to start with test; you can use a more readable name.
import Testing
@testable import Pricing

@Test("finalPrice applies the discount correctly")
func finalPrice_appliesDiscount() {
    let result = PriceCalculator.finalPrice(base: 100, discount: 0.2)
    #expect(result == 80.0)
}

If you want to group them, you put them in a type (struct, class or actor), and that type becomes your “suite”:

import Testing
@testable import Pricing

struct PriceCalculatorTests {

    @Test("Applies the base discount")
    func appliesBaseDiscount() {
        let result = PriceCalculator.finalPrice(base: 100, discount: 0.2)
        #expect(result == 80.0)
    }
}

Apple recommends starting with struct unless you need cleanup in deinit

3. Modern initialization

In XCTest you typically have something like this:

final class UserProfileRepositoryTests: XCTestCase {

    var sut: UserProfileRepository!

    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = UserProfileRepository(api: .mock)
    }

    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }

    func test_loadProfile_returnsUser() throws {
        let user = try sut.loadProfile(id: "123")
        XCTAssertEqual(user.id, "123")
    }
}

In Swift Testing, setup lives in the init of the type that groups your tests, and teardown (if you need it) in the deinit of a class or actor:

import Testing
@testable import Profiles

struct UserProfileRepositoryTests {
    let sut: UserProfileRepository

    init() {
        sut = UserProfileRepository(api: .mock)
    }

    @Test("loadProfile returns a valid user")
    func loadProfile_returnsUser() throws {
        let user = try sut.loadProfile(id: "123")
        #expect(user.id == "123")
    }
}

In Swift Testing, setup lives in the init of the type that groups your tests, and teardown (if you need it) in the deinit of a class or actor:

import Testing
@testable import Profiles

struct UserProfileRepositoryTests {
    let sut: UserProfileRepository

    init() {
        sut = UserProfileRepository(api: .mock)
    }

    @Test("loadProfile returns a valid user")
    func loadProfile_returnsUser() throws {
        let user = try sut.loadProfile(id: "123")
        #expect(user.id == "123")
    }
}

The init can even be throws or async if your setup is more complex.

4. Quick mind map to migrate

XCTestSwift Testing
XCTAssert(x)XCTAssertTrue(x)#expect(x)
XCTAssertFalse(x)#expect(!x)
XCTAssertNil(x)#expect(x == nil)
XCTAssertNotNil(x)#expect(x != nil)
XCTAssertEqual(x, y)#expect(x == y)
XCTAssertNotEqual(x, y)#expect(x != y)
XCTAssertIdentical(x, y)#expect(x === y)
XCTAssertNotIdentical(x, y)#expect(x !== y)
XCTAssertGreaterThan(x, y)#expect(x > y)
XCTAssertGreaterThanOrEqual(x, y)#expect(x >= y)
XCTAssertLessThanOrEqual(x, y)#expect(x <= y)
XCTAssertLessThan(x, y)#expect(x < y)
XCTAssertThrowsError(try f())#expect(throws: (any Error).self) { try f() }
XCTAssertThrowsError(try f()) { error in ... }let error = #expect(throws: (any Error).self) { try f() }
XCTAssertNoThrow(try f())#expect(throws: Never.self) { try f() }
try XCTUnwrap(x)try #require(x)
XCTFail("...")Issue.record("...")

Conclusions

Swift Testing does not come to “eliminate” XCTest, but to make writing tests in Swift feel much more modern and comfortable. XCTest is still very valid (especially for UI tests, performance and legacy projects), but Swift Testing adds several things that are noticeable on a day-to-day basis: less boilerplate, more expressive asserts, native parameters and better error messages.

If your team is already on Xcode 16 / Swift 6, the next practical step is simple: create a test file or two using Swift Testing, run the whole suite and see how it feels. From there, you can decide how much you want to embrace the new style and how fast to migrate.

References

Migrating a test from XCTest

View available Trainings in miCoach
miCoach Training Link