Swift Testing: Getting Started

In 2021, Apple released Swift concurrency to an adoring audience — finally, developers could write Swift code to implement concurrency in Swift apps! At WWDC 2024, developers got another game changer — Swift Testing. By Audrey Tam.

Leave a rating/review
Download materials
Save for later
Share

In 2021, Apple released Swift concurrency to an adoring audience; finally, developers could write Swift code to implement concurrency in Swift apps! At WWDC 2024, developers got another game changer: Swift Testing. It is so much fun to use, you’ll be leaping out of bed every morning, eager to write more unit tests for all your apps! No more gritting your teeth over XCTAssert-this-and-that. You get to write in Swift, using Swift concurrency, no less. Swift Testing is a thing of beauty, and Apple’s testing team is rightfully proud of its achievement. You’ll be able to write tests faster and with greater control, your tests will run on Linux and Windows, and Swift Testing is open source, so you can help to make it even better.

Swift Testing vs. XCTest

Here’s a quick list of differences:

  • You mark a function with @Test instead of starting its name with test.
  • Test functions can be instance methods, static methods, or global functions.
  • Swift Testing has several traits you can use to add descriptive information about a test, customize when or whether a test runs, or modify how a test behaves.
  • Tests run in parallel using Swift concurrency, including on devices.
  • You use #expect(...) or try #require(...) instead of XCTAssertTrue, ...False, ...Nil, ...NotNil, ...Equal, ...NotEqual, ...Identical, ...NotIdentical, ...GreaterThan, ...LessThanOrEqual, ...GreaterThanOrEqual or ...LessThan.

Keep reading to see more details.

Getting Started

Note: You need Xcode 16 beta to use Swift Testing.

Click the Download Materials button at the top or bottom of this article to download the starter projects. There are two projects for you to work with:

Migrating to Swift Testing

To start, open the BullsEye app in Xcode 16 beta and locate BullsEyeTests in the Test navigator.

Test navigator screen

These tests check that BullsEyeGame computes the score correctly when the user’s guess is higher or lower than the target.

First, comment out the last test testScoreIsComputedPerformance(). Swift Testing doesn’t (yet) support UI performance testing APIs like XCTMetric or automation APIs like XCUIApplication.

Return to the top and replace import XCTest with:

import Testing

Then, replace class BullsEyeTests: XCTestCase { with:

struct BullsEyeTests {

In Swift Testing, you can use a struct, actor, or class. As usual in Swift, struct is encouraged because it uses value semantics and avoids bugs from unintentional state sharing. If you must perform logic after each test, you can include a de-initializer. But this requires the type to be an actor or class — it’s the most common reason to use a reference type instead of a struct.

Next, replace setUpWithError() with an init method:

init() {
  sut = BullsEyeGame()
}

This lets you remove the implicit unwrapping from the sut declaration above:

var sut: BullsEyeGame

Comment out tearDownWithError().

Next, replace func testScoreIsComputedWhenGuessIsHigherThanTarget() { with:

@Test func scoreIsComputedWhenGuessIsHigherThanTarget() {

and replace the XCTAssertEqual line with:

#expect(sut.scoreRound == 95)

Similarly, update the second test function to:

@Test func scoreIsComputedWhenGuessIsLowerThanTarget() {
  // 1. given
  let guess = sut.targetValue - 5

  // 2. when
  sut.check(guess: guess)

  // 3. then
  #expect(sut.scoreRound == 95)
}

Then, run BullsEyeTests in the usual way: Click the diamond next to BullsEyeTests in the Test navigator or next to struct BullsEyeTests in the editor. The app builds and runs in the simulator, and then the tests complete with success:

Completed tests

Now, see how easy it is to change the expected condition: In either test function, change == to !=:

#expect(sut.scoreRound != 95)

To see the failure message, run this test and then click the red X:

Failure message

And click the Show button:

Failure message

It shows you the value of sut.scoreRound.

Undo the change back to ==.

Notice the other test groups are still there, and they’re all XCTests. You didn’t have to create a new target to write Swift Testing tests, so you can migrate your tests incrementally. But don’t call XCTest assertion functions from Swift Testing tests or use the #expect macro in XCTests.

Adding Swift Testing

Close BullsEye and open TheMet. This app has no testing target, so add one:

Choosing a template for the target

Testing System defaults to Swift Testing:

Swift Testing is the default option.

Now, look at your new target’s General/Deployment Info:

Target information

Not surprisingly, it’s iOS 18.0. But TheMet’s deployment is iOS 17.4. You can change one or the other, but they need to match. I’ve changed TheMet’s deployment to iOS 18.

Open TheMetTests in the Test navigator to see what you got:

import Testing

struct TheMetTests {

    @Test func testExample() async throws {
        // Write your test here and use APIs like `#expect(...)` to check expected conditions.
    }

}

You’ll need the app’s module, so import that:

@testable import TheMet

You’ll be testing TheMetStore, where all the logic is, so declare it and initialize it:

var sut: TheMetStore

init() async throws {
  sut = TheMetStore()
}

Press Shift-Command-O, type the, then Option-click TheMetStore.swift to open it in an assistant editor. It has a fetchObjects(for:) method that downloads at most maxIndex objects. The app starts with the query “rhino”, which fetches three objects. Replace testExample() with a test to check that this happens:

@Test func rhinoQuery() async throws {
  try await sut.fetchObjects(for: "rhino")
  #expect(sut.objects.count == 3)
}

Run this test … success!

Successful test

Write another test:

@Test func catQuery() async throws {
  try await sut.fetchObjects(for: "cat")
  #expect(sut.objects.count <= sut.maxIndex)
}

Parameterized Testing

Again, it succeeds! These two tests are very similar. Suppose you want to test other query terms. You could keep doing copy-paste-edit, but one of the best features of Swift Testing is parameterized tests. Comment out or replace your two tests with this:

@Test("Number of objects fetched", arguments: [
        "rhino",
        "cat",
        "peony",
        "ocean",
    ])
func objectsCount(query: String) async throws {
  try await sut.fetchObjects(for: query)
  #expect(sut.objects.count <= sut.maxIndex)
}

And run the test:

The Test navigator shows each label and argument tested.

The label and each of the arguments appear in the Test navigator. The four tests ran in parallel, using Swift concurrency. Each test used its own copy of sut. If one of the tests had failed, it wouldn't stop any of the others, and you'd be able to see which ones failed, then rerun only those to find the problem.