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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Swift Testing: Getting Started
20 mins
- Swift Testing vs. XCTest
- Getting Started
- Migrating to Swift Testing
- Adding Swift Testing
- Parameterized Testing
- Parameterized Test With Two Arguments
- Swift Testing Building Blocks
- @Test Functions
- Expectations
- Halting a Test After Failure
- When You Want an Expectation to Fail
- @Suite
- Traits
- Controlling When Tests Run
- Tagging Tests
- Swift Testing Is Open Source and Cross-Platform
Parameterized Test With Two Arguments
You can pass any sendable collection — including array, dictionary, enum, range — as an argument to a test function, and the testing library will pass each element in the collection, one at a time, to the test function as its first and only argument. The tests run in parallel while maintaining thread safety.
You're not restricted to a single argument. Here's an example from Go further with Swift Testing:
enum Ingredient: CaseIterable {
case rice, potato, lettuce, egg
}
enum Dish: CaseIterable {
case onigiri, fries, salad, omelette
}
@Test(arguments: Ingredient.allCases, Dish.allCases)
func cook(_ ingredient: Ingredient, into dish: Dish) async throws {
#expect(ingredient.isFresh)
let result = try cook(ingredient)
try #require(result.isDelicious)
try #require(result == dish)
}
Apple engineers seem to spend a lot of time thinking about food! OK, it's time to get down to some basic building blocks.
Swift Testing Building Blocks
Swift Testing has four building blocks: test functions, expectations, traits, and suites. These are designed to feel Swifty:
- Test functions integrate seamlessly with Swift concurrency by supporting async/await and actor isolation.
- Expectations accept all the built-in language operators and can also use async/await.
- Both expectations and traits leverage Swift macros, allowing you to see detailed failure results and specify per-test information directly in code.
- Suites embrace value semantics, encouraging the use of structs to isolate state.
@Test Functions
You've already tried several @Test
functions. You can also drag in a @Test
code snippet from the Library.
Expectations
You've also used the #expect
macro, which replaces all the XCTAssert
methods. A powerful variant is the
#require
macro — the test fails if a required expectation fails. Here are some examples from Meet Swift Testing:
try #require(session.isValid)
session.invalidate()
let method = try #require(paymentMethods.first)
#expect(method.isDefault)
Failure throws an error, so you always try
a required expectation.
Halting a Test After Failure
Swift Testing uses #require
to give you much more control over handling failure, compared to XCTest:
- In XCTest, you set
continueAfterFailure
tofalse
, then any subsequent assertion that fails halts the test. - In Swift Testing, you can use
#require
instead of#expect
to make an expectation required, and it will throw an error if it fails. This lets you choose which expectations should halt the test.
// XCTest
func testExample() {
self.continueAfterFailure = false
// other assertions, one of which fails
XCTAssertEqual(x, y) // test halts even if this assertion succeeds
}
// Swift Testing
@Test func example() throws {
// other expectations, some of which fail and throw errors
try #require(x == y) // the test halts only if this fails
}
When You Want an Expectation to Fail
This section uses more code from Go further with Swift Testing.
Suppose you expect something to fail. Instead of writing your own do-catch code:
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 3)
do {
try teaLeaves.brew(forMinutes: 100)
} catch is BrewingError {
// This is the code path we are expecting
} catch {
Issue.record("Unexpected Error")
}
}
Use #expect(throws:)
in a test that should throw an error. The error can be general or specific. If the test throws that error, it's a successful test.
@Test func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: BrewingError.oversteeped) {
try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test!
}
}
If you know a test will fail due to a known issue that can't be fixed immediately, use withKnownIssue
:
@Test func softServeIceCreamInCone() throws {
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
}
If the function returns an error, the test’s results doesn't count toward a test failure, because this failure is expected. Instead, the test appears as an expected failure in the results and doesn't add noise that might distract your attention from new problems.
You can run other testing code around the known-issue code.
@Test func softServeIceCreamInCone() throws {
let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
try #require(iceCreamBatter != nil)
#expect(iceCreamBatter.flavor == .chocolate)
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
}
@Suite
A test suite is a group of related tests. Suites may be denoted explicitly by the @Suite
attribute, but it's implicit for any type that contains test functions or nested suites. It's required only when you specify a display name or other trait. Here's a code example from Meet Swift Testing:
struct VideoTests {
@Test("Check video metadata") func videoMetadata() {
let video = Video(fileName: "By the Lake.mov")
let expectedMetadata = Metadata(duration: .seconds(90))
#expect(video.metadata == expectedMetadata)
}
@Test func rating() async throws {
let video = Video(fileName: "By the Lake.mov")
#expect(video.contentRating == "G")
}
}
Suites can have stored instance properties. They can use init
or deinit
to perform logic before or after each test. And a separate @Suite
instance is created for every instance @Test
function it contains to avoid unintentional state sharing.
Traits
Traits can do several things:
- Add descriptive information about a test.
- Customize when or whether a test runs.
- Modify how a test behaves.
Here are some specific traits:
-
@Test("Custom name")
: Customize the display name of a test. This name appears in the Test navigator and other places in Xcode. -
@Test(.bug("example.com/issues/99999", "Title"))
: Reference an issue from a bug tracker. -
@Test(.tags(.critical))
: Add a custom tag to a test. -
@Test(.enabled(if: Server.isOnline))
: Specify a runtime condition for a test. -
@Test(.disabled("Currently broken"))
: Unconditionally disable a test. -
@Test(...) @available(macOS 15, *)
: Limit a test to certain OS versions. -
@Test(.timeLimit(.minutes(3)))
: Set a maximum time limit for a test. -
@Suite(.serialized)
: Run the tests in a suite one at a time, without parallelization.
Controlling When Tests Run
Use the .enabled(if:)
trait to run a test when a condition is true. Use .disabled()
if you want a test to never run. Instead of commenting out a test function, disabling it lets the compiler check its code. You can include a comment to explain the reason why the test is disabled, and comments appear in the structured results, so your CI system can show them. If a test is disabled because of an issue tracked in a bug-tracking system, you can include a .bug(...)
trait to reference related issues with a URL. Then, you can see that bug trait in the Test Report and click to open its URL.
@Test(.disabled("Due to a known crash"),
.bug("example.org/bugs/1234", "Program crashes at <symbol>"))
func example() {
// ...
}
When the entire body of a test can run only on certain OS versions, place the @available(...)
attribute on that test instead of checking at runtime using #available
. This tells the testing library that the test has an OS version condition, so the results contain more accurate information.
// ❌ Avoid checking availability at runtime using #available
@Test func hasRuntimeVersionCheck() {
guard #available(macOS 15, *) else { return }
// ...
}
// ✅ Prefer @available attribute on test function
@Test
@available(macOS 15, *)
func usesNewAPIs() {
// ...
}