Highlight
Swift Testing’s
#expect(throws:)macro replaces do-catch boilerplate in one line;withKnownIssuemarks expected failures without polluting reports; parameterized tests let one test function cover many inputs—these three features are this session’s core additions.
Core Content
When writing tests, error handling is often skipped. With XCTest, testing a throwing function means hand-written do-catch—verbose, and if the function doesn’t throw, do-catch won’t tell you the test didn’t actually verify anything. Swift Testing’s #expect(throws:) macro fixes this: pass an error type; if the function throws that type, pass; if not, fail—one line.
Another pain point: “known failing” tests. A test breaks because of an external dependency you can’t fix yet, but it shows red every run and drowns real failures. Some use .disabled to skip the whole test—but then you miss compile errors too. withKnownIssue is more precise: the test still runs; failures are marked “expected” and don’t count toward the failure total; when fixed, the framework reminds you to remove withKnownIssue.
Parameterized tests address duplication. An enum has six cases—you need to test containsNuts for each. Copy-paste six test functions? Error-prone. for-loop? One failure stops the rest, and errors don’t say which value failed. @Test(arguments:) lifts inputs to a test attribute; the framework generates independent cases per argument, labels failures clearly, and runs all cases in parallel.
Detailed Content
Error assertions: #expect(throws:)
#expect(throws:) offers four precision levels (02:57):
// Only care whether anything throws
#expect(throws: (any Error).self) {
try teaLeaves.brew(forMinutes: 200)
}
// Care about the error type
#expect(throws: BrewingError.self) {
try teaLeaves.brew(forMinutes: 200)
}
// Match a specific case
#expect(throws: BrewingError.oversteeped) {
try teaLeaves.brew(forMinutes: 200)
}
// Custom validation (associated values, etc.)
#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
}
Key points:
throws: (any Error).self— pass if anything throwsthrows: BrewingError.self— fail if a different type throwsthrows: BrewingError.oversteeped— exact case match including associated values- Closure form — most flexible; closure returns
Bool
#require(throws:) is the must-pass variant (04:11): on failure, the test stops immediately.
Known issues: withKnownIssue
@Test func softServeIceCreamInCone() throws {
let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
try #require(iceCreamBatter != nil)
#expect(iceCreamBatter.flavor == .chocolate)
withKnownIssue {
try softServeMachine.makeSoftServe(in: .cone)
}
}
Key points (04:29):
withKnownIssuewraps only the failing part; other assertions still run- Failures record as “expected failure,” not test failure
- When the underlying issue is fixed, the framework prompts removal
- Better than
.disabled: test still compiles and runs
Parameterized tests
@Test(arguments: [IceCream.Flavor.vanilla, .chocolate, .strawberry, .mintChip])
func doesNotContainNuts(flavor: IceCream.Flavor) throws {
try #require(!flavor.containsNuts)
}
@Test(arguments: [IceCream.Flavor.rockyRoad, .pistachio])
func containNuts(flavor: IceCream.Flavor) {
#expect(flavor.containsNuts)
}
Key points (08:44):
@Test(arguments:)accepts any Sendable collection (array, dictionary, Range, etc.)- Each argument becomes an independent test case with precise failure labels
- All cases run in parallel—faster than for-loops
- When inputs are
Codable, Xcode can re-run individual failed cases
Multi-parameter combinations (11:07):
@Test(arguments: Ingredient.allCases, Dish.allCases)
func cook(_ ingredient: Ingredient, into dish: Dish) async throws { ... }
Two collections form a Cartesian product—4 ingredients × 4 dishes = 16 cases. To pair instead of cross-multiply, use zip:
@Test(arguments: zip(Ingredient.allCases, Dish.allCases))
func cook(_ ingredient: Ingredient, into dish: Dish) async throws { ... }
zip pairs in order—only 4 cases.
Suite, Tag, and parallel tests
@Suite("Various desserts")
struct DessertTests {
@Suite struct WarmDesserts {
@Test func applePieCrustLayers() { /* ... */ }
@Test func lavaCakeBakingTime() { /* ... */ }
}
@Suite struct ColdDesserts {
@Test func cheesecakeBakingStrategy() { /* ... */ }
@Test func mangoSagoToppings() { /* ... */ }
}
}
Suites support nesting (13:11) for logical grouping.
Tags associate tests across suites (13:36):
extension Tag {
@Tag static var caffeinated: Self
@Tag static var chocolatey: Self
}
@Suite(.tags(.caffeinated)) struct DrinkTests { ... }
@Test(.tags(.caffeinated, .chocolatey)) func espressoBrownieTexture() { ... }
Tags appear in Xcode 16’s test navigator and reports. Filter include/exclude by Tag in test plans; distribution insights can find commonalities among failures sharing a Tag.
Parallel tests are on by default (21:20). Order is randomized to expose hidden data dependencies. If you can’t fix dependencies yet, use .serialized on a Suite:
@Suite("Cupcake tests", .serialized)
struct CupcakeTests { ... }
.serialized inherits to nested child Suites—no need to repeat.
Async callback tests: confirmation
For callbacks that may fire multiple times, use confirmation instead of manual counting (25:50):
@Test 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()
}
}
}
expectedCount sets how many callbacks you expect. Mismatch fails the test. Pass 0 to assert a callback should never fire.
Core Takeaways
-
Replace all do-catch test patterns with
#expect(throws:): Why it’s worth it: do-catch is verbose and easy to miss “didn’t throw”;#expect(throws:)is one line and clearer. How to start: Find do-catch blocks testing throws; replace with#expect(throws: ErrorType.self). -
Use parameterized tests to eliminate duplicate test functions: Why it’s worth it: Same logic, different inputs—copy-paste is error-prone; parameterized tests separate inputs from logic; new cases are one array entry. How to start: Find tests that differ only by input; merge with
@Test(arguments:). -
Use
withKnownIssuefor “can’t fix yet” failures: Why it’s worth it:.disabledskips entirely, hiding compile errors;withKnownIssueruns the test, excludes failures from totals, reminds you when fixed. How to start: Wrap repeatedly failing CI tests you won’t fix yet. -
Use Tags across files with test plans for precise filtering: Why it’s worth it: Directory layout isn’t enough at scale; Tags group by business dimension (paid features, network deps) for include/exclude in plans. How to start: Tag key dimensions; filter by Tag in test plan include/exclude.
Related Sessions
- Meet Swift Testing — Swift Testing intro: @Test, #expect, Suite basics
- Consume noncopyable types in Swift — Noncopyable types in Swift 6 and testing
- Explore Swift performance — Swift performance tuning; parallel test impact
- Explore the Swift on Server ecosystem — Testing framework practice on server-side Swift
Comments
GitHub Issues · utterances