WWDC Quick Look đź’“ By SwiftGGTeam
Meet Swift Testing

Meet Swift Testing

Watch original video

Highlight

Swift Testing is a new open-source testing package introduced this year. It can coexist with XCTest in the same target, and Xcode 16 selects it by default when you create a new test target.


Core Content

The most annoying part of writing tests isn’t the tests themselves—it’s XCTest’s boilerplate. Every test must inherit from XCTestCase, method names must start with test, you have to memorize a pile of XCTAssertXxx function signatures, and setUp/tearDown lifecycles need careful handling. None of that is “test logic”—it’s framework overhead.

Swift Testing minimizes that framework noise. You only need two things: @Test to mark functions and #expect for assertions. No class inheritance, no dozen assertion functions to remember—#expect accepts ordinary Swift expressions and automatically captures subexpression values on failure. When creating a new test target in Xcode 16, Swift Testing is already the default option.

The framework’s four building blocks are: @Test marks test functions, #expect/#require perform assertions, Traits customize test behavior, and Suites organize test structure. Each test function creates an independent instance when run in a Suite, avoiding the shared-state problems common in XCTestCase. The entire framework uses an open development process—community members can contribute and propose changes on GitHub.


Detailed Content

Write Your First Test

Import the Testing module, mark a global function with @Test, and assert with #expect (01:54):

import Testing
@testable import DestinationVideo

@Test func videoMetadata() {
    let video = Video(fileName: "By the Lake.mov")
    let expectedMetadata = Metadata(duration: .seconds(90))
    #expect(video.metadata == expectedMetadata)
}

Key points:

  • import Testing brings in the framework; @testable import is a Swift language feature for accessing internal members
  • @Test can annotate global functions or methods on types, and supports async, throws, and global actor isolation
  • #expect accepts ordinary expressions and operators, automatically capturing source and both sides’ values on failure

#require: Early Test Termination

#require is a strict version of #expect for optional unwrapping or scenarios where you need to terminate the test early (02:35):

@Test(.tags(.formatting)) func formattedDuration() async throws {
    let videoLibrary = try await VideoLibrary()
    let video = try #require(await videoLibrary.video(named: "By the Lake"))
    #expect(video.formattedDuration == "0m 19s")
}

Key points:

  • #require needs try; it throws and terminates the test when the expression is false or the optional is nil
  • Best for scenarios where later code depends on earlier results—continuing makes no sense when the value doesn’t exist

Traits: Customize Test Behavior

Traits are the third building block—they add descriptive info, control run conditions, and modify behavior (06:06):

@Test(.disabled("Due to a known crash"),
      .bug("example.org/bugs/1234", "Program crashes at <symbol>"))
func example() {
    // ...
}

Key points:

  • .disabled(...) disables a test but keeps the code compiling—safer than commenting out the function
  • .bug(...) links to issue tracking; clickable directly from the Test Report in Xcode 16
  • .enabled(if:) controls run conditions; marks as skipped when the condition isn’t met
  • .tags(...) labels tests for grouping across files and targets; filter and run by tag in Test Navigator

Suite: Organize Test Structure

Wrap test functions in a struct to form a Suite (07:18):

@Suite(.tags(.formatting))
struct MetadataPresentation {
    let video = Video(fileName: "By the Lake.mov")

    @Test func rating() async throws {
        #expect(video.contentRating == "G")
    }

    @Test func formattedDuration() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "By the Lake"))
        #expect(video.formattedDuration == "0m 19s")
    }
}

Key points:

  • Suites encourage structs—value semantics avoid shared state; each @Test method creates an independent Suite instance
  • Stored properties replace traditional setUp; init replaces setUp, deinit replaces tearDown (deinit requires class or actor)
  • @Suite attributes can specify display name and traits; child Suites and inner tests inherit them

Parameterized Tests

Merge repetitive test functions into one parameterized test (14:07):

@Test("Number of mentioned continents", arguments: [
    "A Beach",
    "By the Lake",
    "Camping in the Woods",
    "The Rolling Hills",
    "Ocean Breeze",
    "Patagonia Lake",
    "Scotland Coast",
    "China Paddy Field",
])
func mentionedContinentCounts(videoName: String) async throws {
    let videoLibrary = try await VideoLibrary()
    let video = try #require(await videoLibrary.video(named: videoName))
    #expect(!video.mentionedContinents.isEmpty)
    #expect(video.mentionedContinents.count <= 3)
}

Key points:

  • Add parameters to the function signature; @Test’s arguments: specifies input values
  • Each parameter value appears as a separate test entry in Test Navigator—rerun and debug individually
  • Better than for…in loops: each parameter’s result is visible independently, supports parallel execution, and you can rerun only failed parameters

Relationship with XCTest

Both can coexist in the same test target (17:36). UI automation (XCUIApplication) and performance testing (XCTMetric) still require XCTest. Migration tips: multiple structurally similar XCTest methods can merge into one parameterized test; an XCTestCase with only one test method can migrate to a global @Test function. Don’t call XCTAssert functions in Swift Testing tests, and don’t call #expect in XCTestCase.


Core Takeaways

  1. Use parameterized tests to eliminate duplicate test code: Many “same logic, different input” test functions in a project are a maintenance burden. Merge them into parameterized tests—adding a new test case is just one line in the arguments array. How to start: find tests distinguished by suffix in function names (e.g., testVideoA, testVideoB) and extract shared logic into a parameterized function.

  2. Use .tags instead of file directories to organize tests: The traditional approach groups tests by folder, but cross-module relationships are hard to express. .tags marks tests across files and targets; filter and run by tag in Test Navigator, and see correlated insights when multiple tests under the same tag fail in Test Report. How to start: define a Tag for each core feature/subsystem and gradually tag existing tests.

  3. Use .disabled + .bug instead of commented-out tests: Commented-out tests don’t participate in compilation—the code may have rotted. .disabled keeps code always compiling; .bug links to issue URLs for traceable disable reasons. How to start: search for all commented-out test functions and replace with .disabled("reason").bug("link").

  4. Use Swift Testing directly for new projects, introduce gradually in old ones: Both frameworks can coexist in the same target without creating a new one. Add new @Test functions alongside existing XCTestCase to experience #expect’s diagnostics, then migrate gradually once the team adapts. How to start: import Testing in an existing test target and write one new @Test function to run through the flow.


Comments

GitHub Issues · utterances