WWDC24 Highlights: Apple's New Testing Framework
I didn’t think I needed or wanted another testing framework. Turns out it takes ten minutes to learn and actually makes my life easier. What’s not to like?
Assertions
The new framework replaces all XCTAssert functions with just three:
- #expect()
- #expect(throws:)
- #require()
import Testing
// test passes if expression returns true
#expect(Color.black.toHex() == "000000")
// test passes if expression throws
#expect(throws: ThemeError.self) {
try convertToHex(Color.black)
}
// test ends early if value is nil. Similar to XCTUnwrap.
_ = try #require(Int("1"))
// another way
do {
_ = try convertToHex(Color.black)
} catch ThemeError.wrongColor {
} catch {
Issue.record(error, "Unexpected error")
}
// test passes if #require fails
withKnownIssue {
_ = try #require(Int("A"))
}
Mind the change:
XCTAssertEquals(a, b)
#expect(a == b)
Natural language assertions are deprecated in favor of more expressive and concise APIs. We see this trend in many features of the language: async/await, trailing closure syntax, property wrappers, result builders, implicit return, keypath expressions, macros, etc.
Apple explicitly commented on this change on the document ‘A New Direction for Testing in Swift’. They favor a concise API that is easy to learn and maintain, without specialized matchers.
Organizing Tests
A test is a function annotated with @Test.
import Testing
// All assertions must be inside a function annotated with @Test
@Test
func colorToHex() throws {
#expect(Color.black.toHex() == "000000")
}
// Test functions can be global or be grouped inside a struct, actor, or class.
struct Colors {
@Test
func colorToHex() throws {
#expect(Color.black.toHex() == "000000")
}
}
Organizing Tests in Suites
Optionally, functions may be grouped in objects and be annotated with @Suite.
import Testing
// Objects containing tests may optionally be labeled
@Suite("A test demonstration")
struct TestSuite {
// ...
}
// Tests run in random parallel order unless they have the trait '.serialized'.
@Suite("A test demonstration", .serialized)
struct TestSuite {
// nested suites will inherit the .serialized argument
@Suite struct TestSuite { ... }
}
Fine print:
- Suites can be any type (struct, enum, class, or actor), but classes must be final. Note that classes may inherit from another, but tests can only run from those that are final.
- A suite object must have an initializer without arguments. It doesn’t matter its kind, whether it is private, async, throwing or not.
- Suites can be nested.
- A separate instance of the object is created to run each test function. This means you can use init/deinit as replacement for XCTest setup/teardown.
Organizing Tests with Tags
Objects and functions may have arbitrary tags created by the user. This enhances your ability to group them in different ways in the Xcode test navigator.
import Testing
// To create custom tags extend Apple’s Tag
extension Tag {
@Tag static var caffeinated: Self
@Tag static var chocolatey: Self
}
// then add your tag to suites and/or tests
@Suite(.tags(.caffeinated))
struct OneMoreSuite {
@Test(.tags(.caffeinated, .chocolatey))
func whatever() {/*...*/}
}
Tests Traits
Optionally, add more traits to your tests:
import Testing
@Test("Custom name") // Custom name
@Test(.bug("myjira.com/issues/999", "Title") // Related bug report
@Test(.tags(.critical)) // Custom tag
@Test(.enabled(if: Server.isOnline)) // Enabled by runtime condition
@Test(.disabled("Currently broken")) // Disabled
@Test(.timeLimit(.minutes(3))) // Maximum time
@Test @available(macOS 15, *) // Run on specific OS versions
Parameterizing functions
Did you ever tested values from an array of data? Now you can declare your intention to do so and let the library record the results. This is accomplished using the @Test arguments trait.
import Testing
// This calls the function three times.
// Note that first enum case passed as argument
// needed the explicit type, the rest were inferred.
@Test(arguments: [Flavor.vanilla, .chocolate, .strawberry])
func doesNotContainNuts1(flavor: Flavor) throws {
try #require(!flavor.containsNuts)
}
// Passing allCases will call with all permutations
// of the possible values for each argument.
@Test(arguments: Flavor.allCases, Dish.allCases)
func doesNotContainNuts2(flavor: Flavor, dish: Dish) throws {
try #require(!flavor.containsNuts)
}
// This makes pairs, then calls with each pair.
@Test(arguments: zip(Flavor.allCases, Dish.allCases))
func doesNotContainNuts2(flavor: Flavor, dish: Dish) throws {
try #require(!flavor.containsNuts)
}
Noteworthy:
- Enums can also be passed as allCases if they support CaseIterable.
- You may also pass Array, Set, OptionSet, Dictionary, and Range.
- Tests can be parameterized with a maximum of two collections.
Testing Asynchronous Conditions
As with XCTest, you may add async and/or throws to the signature of a test function. If your test should run in @MainActor, you are free to add that too.
import Testing
// testing async calls
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
#expect(cookies.count == 10)
}
// testing completion handlers
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await withCheckedThrowingContinuation { continuation in
eat(cookies, with: .milk) { result, error in
if let result {
continuation.resume(returning: result)
} else if let error {
continuation.resume(throwing: error)
}
}
}
}
// testing number of calls
@Test func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await confirmation("Ate cookies", expectedCount: 0) { ateCookie in
try await eat(cookies, with: .milk) { cookie, crumbs in
ateCookie()
}
}
}
Compatibility with XCTest
You can mix XCTest and Testing in the same target. Migrating is easy: drop every XCTest reference and add the Testing annotations we saw in this document.
References
At Apple
- WWDC 24 Meet Swift Testing
- WWDC 24 Go further with Swift Testing
- https://developer.apple.com/documentation/Testing
The testing library is open source, multiplatform, and has its own topic in the Swift forums.