Migrating XCTest to Swift Testing

A quick guide to migrating XCTest unit tests to Swift Testing.

What is Swift Testing?

Swift Testing is an open-source testing framework designed for Swift. Apple introduced it at WWDC24 and shipped it with Xcode 16. Swift Testing uses modern features like concurrency and macros. It supports Windows and Linux as well as Apple’s platforms.

Do I Need To Migrate?

No. The XCTest framework is not deprecated, There’s no urgent reason to migrate your tests unless you want to. You cannot migrate UI Automation tests or performance (XCTMetric) tests as they are not supported by Swift Testing.

I’m finding that I prefer writing tests with Swift Testing over XCTest (see below for some of the reasons). I started adding new tests with Swift Testing to XCTest unit test source files but at a certain point I want to migrate all the tests in a source file to Swift Testing. These are my notes on that process.

Getting Started

You can mix XCTest and Swift Testing unit tests in a test target. You can even mix both types of test in the same source file. That make it possible to add new tests with Swift Testing and migrate your XCTests over time.

Do not mix testing frameworks within a test. Do not call XCTAssert from a Swift Testing test or a Swift Testing macro like #expect or #require from XCTests.

Import Swift Testing

To get started import the Swift Testing framework:

import Testing

Apple warns that you should only import the testing library into a test target. Importing into an application or library target is not supported.

Grouping Tests

You group Swift Testing unit tests by adding them to a type (struct or class). Xcode organises your tests based on your chosen grouping in the Test Navigator.

Unlike XCTest which requires you subclass XCTestCase, Swift Testing can use a struct, class or actor. Apple recommends you start with a struct unless you need a deinit for cleanup (see below).

// XCTest
import XCTest

class StoreXCTests: XCTestCase {
}

// Swift Testing
import Testing

struct StoreTests {
}

Swift Testing creates a new instance of the StoreTests type for each unit test it runs. You’re not forced to organise Swift Testing tests in a type. For example, Apple recommends you convert an XCTestCase subclass with a single test to a global function.

Test Setup and Teardown

Swift Testing uses the types init (and deinit for class) methods to setup and teardown tests. I find this a big improvement over the XCTest approach of overriding one of the many setUp and tearDown methods. The init can be async or throwing as needed:

// XCTest
import XCTest

class StoreXCTests: XCTestCase {
  let store: DataStore!

  override func setupWithError() throws {
    store = try DataStore(inMemory: true)
  }
}

// Swift Testing
import Testing

struct StoreTests {
  let store: DataStore

  init() throws {
    store = try DataStore(inMemory: true)
  }
}

For convenience I was using a forced unwrapped optional for the stored property in my XCTest. Using the normal Swift init method for my test type removes the need for that. As I mentioned above if you need to cleanup after the test runs, use a class and add the cleanup code to the deinit:

// Swift Testing
import Testing

class StoreTests {
  let store: DataStore

  init() throws {
    store = try DataStore(inMemory: true)
  }

  deinit {
    store.reset()
  }
}

Creating Tests

A Swift Testing test is a normal Swift method. It can be a standalone global function or one of many methods organised into a Swift struct, class, or actor. A method becomes a unit test when you add the @Test macro:

  // XCTest
  func testStoreIsReady() {
  }

  // Swift Testing
  @Test func storeIsReady() {
  }

As with XCTest, you can mark test methods with async or throws and isolate them to an actor as needed. Unlike with XCTest, you don’t need to prefix your test method name with “test”. The @Test attribute is a macro so you can expand it to see the implementation if you wish.

XCTest uses multiple Simulator instances to run tests in parallel. It doesn’t support running tests in-parallel on device. Swift Testing runs tests in-process using Swift Concurrency so it can run tests in-parallel on physical devices.

Asserting with #expect and #require.

For me, the biggest improvement over XCTest is replacing the forty odd variations of XCTAssert with the #expect and #require macros.

The #expect macro accepts a Swift expression that you expect to be true. When the expression is false the #expect macro logs the failed expectation and the test continues:

  @Test func storeIsReady() {
    #expect(store.isReady)
    #expect(store.error == nil)
  }

The error messages are generally more informative than the equivalent XCTest failure:

Expectation failed red error message

Clicking the red cross in the error message and then the Show button expands the values in the expression which I find is sometimes more readable:

Expanded test result showing isReady is false

It took me a little while to get used to but I prefer the flexibility of being able to use any Swift expression for the #expect rather than trying to remember which version of XCTAssert I need:

// XCTest
XCTAssertTrue(store.isReady)
XCTAssertFalse(store.isReady)
XCTAssertNil(store.error)
XCTAssertNotNil(store.error)
XCTAssertEqual(items.count, 5)

// Swift Testing
#expect(store.isReady)
#expect(!store.isReady)
#expect(store.error == nil)
#expect(store.error != nil)
#expect(items.count == 5)

Unwrapping with #require

The second expectation macro is #require. This is throwing version of #expect that stops the test execution on error. A common usage is to replace XCTUnwrap when unwrapping an optional. If the optional is nil the test stops:

// XCTest
func testCreateItem() throws {
  // createItem returns an Item?
  let item = try XCTUnwrap(store.createItem())
  XCTAssertEqual(item.title == "New item")
}

// Swift Testing
@Test func createItem() throws {
  // createItem returns an Item?
  let item = try #require(store.createItem())
  #expect(item.title == "New item")
}

Replacing XCTFail

Sometimes you need to cause a test to fail without evaluating a condition. For example, when certain cases in a switch are test failures. The XCTest framework has XCTFail for this. The Swift Testing equivalent is Issue.record:

// Swift Testing
@Test func storeReady() {
  switch store.state {
  case .failed: Issue.record("Expected ready, got failed")
  case .loading: Issue.record("Expected ready, got loading")
  case .ready:
    #expect(store.error == nil)
  }
}

Wrapping Up

There’s a lot more you can do to customize tests, control when and how they run, handle async code, and organize with tags and test suites. I’ll save those topics for future posts. In the meantime, I recommend browsing the Apple article linked below for more details on migrating from XCTest.

Learn More