2024 SwiftDeveloper Tools
WWDC24 · 27 min · Swift / Developer Tools
Go further with Swift Testing
Learn how to write a sweet set of (test) suites using Swift Testing’s baked-in features. Discover how to take the building blocks further and use them to help expand tests to cover more scenarios, organize your tests across different suites, and optimize your tests to run in parallel.
Watch at developer.apple.com ↗Chapters
- 0:00 — Introduction
- 0:36 — Why we write tests
- 0:51 — Challenges in testing
- 1:21 — Writing expressive code
- 1:35 — Expectations
- 3:58 — Required expectations
- 4:29 — Tests with known issues
- 5:54 — Custom test descriptions
- 7:23 — Parameterized testing
- 12:47 — Organizing tests
- 12:58 — Test suites
- 13:33 — The tag trait
- 20:38 — Xcode Cloud support
- 21:09 — Testing in parallel
- 21:36 — Parallel testing basics
- 24:26 — Asynchronous conditions
- 26:32 — Wrap up
Code shown on screen · 42 snippets
Successful throwing function
// Expecting errors
import Testing
func brewTeaSuccessfully() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
let cupOfTea = try teaLeaves.brew(forMinutes: 3)
} Validating a successful throwing function
import Testing
func brewTeaSuccessfully() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
let cupOfTea = try teaLeaves.brew(forMinutes: 3)
#expect(cupOfTea.quality == .perfect)
} Validating an error is thrown with do-catch (not recommended)
import Testing
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")
}
} Validating a general error is thrown
import Testing
func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: (any Error).self) {
try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test!
}
} Validating a type of error
import Testing
func brewTeaError() throws {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect(throws: BrewingError.self) {
try teaLeaves.brew(forMinutes: 200) // We don't want this to fail the test!
}
} Validating a specific error
import Testing
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!
}
} Complicated validations
import Testing
func brewTea() {
let teaLeaves = TeaLeaves(name: "EarlGrey", optimalBrewTime: 4)
#expect {
try teaLeaves.brew(forMinutes: 3)
} throws: { error in
guard let error = error as? BrewingError,
case let .needsMoreTime(optimalBrewTime) = error else {
return false
}
return optimalBrewTime == 4
}
} Throwing expectation
import Testing
func brewAllGreenTeas() {
#expect(throws: BrewingError.self) {
brewMultipleTeas(teaLeaves: ["Sencha", "EarlGrey", "Jasmine"], time: 2)
}
} Required expectations
import Testing
func brewAllGreenTeas() throws {
try #require(throws: BrewingError.self) {
brewMultipleTeas(teaLeaves: ["Sencha", "EarlGrey", "Jasmine"], time: 2)
}
} Control flow of validating an optional value (not recommended)
import Testing
struct TeaLeaves {symbols
let name: String
let optimalBrewTime: Int
func brew(forMinutes minutes: Int) throws -> Tea { ... }
}
func brewTea() throws {
let teaLeaves = TeaLeaves(name: "Sencha", optimalBrewTime: 2)
let brewedTea = try teaLeaves.brew(forMinutes: 100)
guard let color = brewedTea.color else {
Issue.record("Tea color was not available!")
}
#expect(color == .green)
} Failing test with a throwing function
import Testing
func softServeIceCreamInCone() throws {
try softServeMachine.makeSoftServe(in: .cone)
} Disabling a test with a throwing function (not recommended)
import Testing
(.disabled) func softServeIceCreamInCone() throws {
try softServeMachine.makeSoftServe(in: .cone)
} Wrapping a failing test in withKnownIssue
import Testing
func softServeIceCreamInCone() throws {
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
} Wrap just the failing section in withKnownIssue
import Testing
func softServeIceCreamInCone() throws {
let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
try #require(iceCreamBatter != nil)
#expect(iceCreamBatter.flavor == .chocolate)
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
} Simple enumerations
import Testing
enum SoftServe {
case vanilla, chocolate, pineapple
} Complex types
import Testing
struct SoftServe {
let flavor: Flavor
let container: Container
let toppings: [Topping]
}
(arguments: [
SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream])
])
func softServeFlavors(_ softServe: SoftServe) { /*...*/ } Conforming to CustomTestStringConvertible
import Testing
struct SoftServe: CustomTestStringConvertible {
let flavor: Flavor
let container: Container
let toppings: [Topping]
var testDescription: String {
"\(flavor) in a \(container)"
}
}
(arguments: [
SoftServe(flavor: .vanilla, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .chocolate, container: .cone, toppings: [.sprinkles]),
SoftServe(flavor: .pineapple, container: .cup, toppings: [.whippedCream])
])
func softServeFlavors(_ softServe: SoftServe) { /*...*/ } An enumeration with a computed property
extension IceCream {
enum Flavor {
case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio
var containsNuts: Bool {
switch self {
case .rockyRoad, .pistachio:
return true
default:
return false
}
}
}
} A test function for a specific case of an enumeration
import Testing
func doesVanillaContainNuts() throws {
try #require(!IceCream.Flavor.vanilla.containsNuts)
} Separate test functions for all cases of an enumeration
import Testing
func doesVanillaContainNuts() throws {
try #require(!IceCream.Flavor.vanilla.containsNuts)
}
func doesChocolateContainNuts() throws {
try #require(!IceCream.Flavor.chocolate.containsNuts)
}
func doesStrawberryContainNuts() throws {
try #require(!IceCream.Flavor.strawberry.containsNuts)
}
func doesMintChipContainNuts() throws {
try #require(!IceCream.Flavor.mintChip.containsNuts)
}
func doesRockyRoadContainNuts() throws {
try #require(!IceCream.Flavor.rockyRoad.containsNuts)
} Parameterizing a test with a for loop (not recommended)
import Testing
extension IceCream {
enum Flavor {
case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio
}
}
func doesNotContainNuts() throws {
for flavor in [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip] {
try #require(!flavor.containsNuts)
}
} Swift testing parameterized tests
import Testing
extension IceCream {
enum Flavor {
case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio
}
}
(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip])
func doesNotContainNuts(flavor: IceCream.Flavor) throws {
try #require(!flavor.containsNuts)
} 100% test coverage
import Testing
extension IceCream {
enum Flavor {
case vanilla, chocolate, strawberry, mintChip, rockyRoad, pistachio
}
}
(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip])
func doesNotContainNuts(flavor: IceCream.Flavor) throws {
try #require(!flavor.containsNuts)
}
(arguments: [IceCream.Flavor.rockyRoad, .pistachio])
func containNuts(flavor: IceCream.Flavor) {
#expect(flavor.containsNuts)
} A parameterized test with one argument
import Testing
enum Ingredient: CaseIterable {
case rice, potato, lettuce, egg
}
(arguments: Ingredient.allCases)
func cook(_ ingredient: Ingredient) async throws {
#expect(ingredient.isFresh)
let result = try cook(ingredient)
try #require(result.isDelicious)
} Adding a second argument to a parameterized test
import Testing
enum Ingredient: CaseIterable {
case rice, potato, lettuce, egg
}
enum Dish: CaseIterable {
case onigiri, fries, salad, omelette
}
(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)
} Using zip() on arguments
import Testing
enum Ingredient: CaseIterable {
case rice, potato, lettuce, egg
}
enum Dish: CaseIterable {
case onigiri, fries, salad, omelette
}
(arguments: zip(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)
} Suites
("Various desserts")
struct DessertTests {
func applePieCrustLayers() { /* ... */ }
func lavaCakeBakingTime() { /* ... */ }
func eggWaffleFlavors() { /* ... */ }
func cheesecakeBakingStrategy() { /* ... */ }
func mangoSagoToppings() { /* ... */ }
func bananaSplitMinimumScoop() { /* ... */ }
} Nested suites
import Testing
("Various desserts")
struct DessertTests {
struct WarmDesserts {
func applePieCrustLayers() { /* ... */ }
func lavaCakeBakingTime() { /* ... */ }
func eggWaffleFlavors() { /* ... */ }
}
struct ColdDesserts {
func cheesecakeBakingStrategy() { /* ... */ }
func mangoSagoToppings() { /* ... */ }
func bananaSplitMinimumScoop() { /* ... */ }
}
} Separate suites
struct DrinkTests {
func espressoExtractionTime() { /* ... */ }
func greenTeaBrewTime() { /* ... */ }
func mochaIngredientProportion() { /* ... */ }
}
struct DessertTests {
func espressoBrownieTexture() { /* ... */ }
func bungeoppangFilling() { /* ... */ }
func fruitMochiFlavors() { /* ... */ }
} Separate suites
struct DrinkTests {
func espressoExtractionTime() { /* ... */ }
func greenTeaBrewTime() { /* ... */ }
func mochaIngredientProportion() { /* ... */ }
}
struct DessertTests {
func espressoBrownieTexture() { /* ... */ }
func bungeoppangFilling() { /* ... */ }
func fruitMochiFlavors() { /* ... */ }
} Using a tag
import Testing
extension Tag {
static var caffeinated: Self
}
(.tags(.caffeinated)) struct DrinkTests {
func espressoExtractionTime() { /* ... */ }
func greenTeaBrewTime() { /* ... */ }
func mochaIngredientProportion() { /* ... */ }
}
struct DessertTests {
(.tags(.caffeinated)) func espressoBrownieTexture() { /* ... */ }
func bungeoppangFilling() { /* ... */ }
func fruitMochiFlavors() { /* ... */ }
} Declare and use a second tag
import Testing
extension Tag {
static var caffeinated: Self
static var chocolatey: Self
}
(.tags(.caffeinated)) struct DrinkTests {
func espressoExtractionTime() { /* ... */ }
func greenTeaBrewTime() { /* ... */ }
(.tags(.chocolatey)) func mochaIngredientProportion() { /* ... */ }
}
struct DessertTests {
(.tags(.caffeinated, .chocolatey)) func espressoBrownieTexture() { /* ... */ }
func bungeoppangFilling() { /* ... */ }
func fruitMochiFlavors() { /* ... */ }
} Two tests with an unintended data dependency (not recommended)
import Testing
// ❌ This code is not concurrency-safe.
var cupcake: Cupcake? = nil
func bakeCupcake() async {
cupcake = await Cupcake.bake(toppedWith: .frosting)
// ...
}
func eatCupcake() async {
await eat(cupcake!)
// ...
} Serialized trait
import Testing
("Cupcake tests", .serialized)
struct CupcakeTests {
var cupcake: Cupcake?
func mixingIngredients() { /* ... */ }
func baking() { /* ... */ }
func decorating() { /* ... */ }
func eating() { /* ... */ }
} Serialized trait with nested suites
import Testing
("Cupcake tests", .serialized)
struct CupcakeTests {
var cupcake: Cupcake?
("Mini birthday cupcake tests")
struct MiniBirthdayCupcakeTests {
// ...
}
(arguments: [...]) func mixing(ingredient: Food) { /* ... */ }
func baking() { /* ... */ }
func decorating() { /* ... */ }
func eating() { /* ... */ }
} Using async/await in a test
import Testing
func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await eat(cookies, with: .milk)
} Using a function with a completion handler in a test (not recommended)
import Testing
func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
// ❌ This code will run after the test function returns.
eat(cookies, with: .milk) { result, error in
#expect(result != nil)
}
} Replacing a completion handler with an asynchronous function call
import Testing
func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await eat(cookies, with: .milk)
} Using withCheckedThrowingContinuation
import Testing
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 {
continuation.resume(throwing: error)
}
}
}
} Callback that invokes more than once (not recommended)
import Testing
func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
// ❌ This code is not concurrency-safe.
var cookiesEaten = 0
try await eat(cookies, with: .milk) { cookie, crumbs in
#expect(!crumbs.in(.milk))
cookiesEaten += 1
}
#expect(cookiesEaten == 10)
} Confirmations on callbacks that invoke more than once
import Testing
func bakeCookies() async throws {
let cookies = await Cookie.bake(count: 10)
try await confirmation("Ate cookies", expectedCount: 10) { ateCookie in
try await eat(cookies, with: .milk) { cookie, crumbs in
#expect(!crumbs.in(.milk))
ateCookie()
}
}
} Confirmation that occurs 0 times
import Testing
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
#expect(!crumbs.in(.milk))
ateCookie()
}
}
} Resources
Related sessions
-
24 min -
34 min