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 withtest
. - 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(...)
ortry #require(...)
instead ofXCTAssertTrue
,...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.
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:
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:
And click the Show button:
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:
Testing System defaults to Swift Testing:
Now, look at your new target’s General/Deployment Info:
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!
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 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.