WWDC Quick Look đź’“ By SwiftGGTeam
Go further with Swift Testing

Go further with Swift Testing

Watch original video

Highlight

Swift Testing’s #expect(throws:) macro replaces do-catch boilerplate in one line; withKnownIssue marks 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 throws
  • throws: BrewingError.self — fail if a different type throws
  • throws: 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):

  • withKnownIssue wraps 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

  1. 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).

  2. 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:).

  3. Use withKnownIssue for “can’t fix yet” failures: Why it’s worth it: .disabled skips entirely, hiding compile errors; withKnownIssue runs the test, excludes failures from totals, reminds you when fixed. How to start: Wrap repeatedly failing CI tests you won’t fix yet.

  4. 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.

Comments

GitHub Issues · utterances