Re:source
WWDC 2024 - Understanding Swift Testing

WWDC 2024 - Understanding Swift Testing

WWDC 2024 - Understanding Swift Testing

Translation Notice

This content is automatically translated from Chinese by AI. While we strive for accuracy, there might be nuances that are lost in translation.

Building Block

import Testing
@testable import DestinationVideo

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

The @Test macro is used to mark this function as a test;

The default access modifier is internal, with the scope being the entire module. @testable essentially lifts this restriction, allowing tests to access it.

The #expect macro is used to test whether a condition is true.

The #require macro is used for assertions. If the condition is false, it will throw an exception and immediately stop the test.

try #require(session.isValid)

session.invalidate() // not executed

It can also be used to assert optional types, exiting the test if the value is nil.

let method = try #require(paymentMethods.first)

#expect(method.isDefault) // not executed

Traits

  • Display test descriptions

  • Customize when and whether test cases run

  • Modify the behavior of test case execution

Some built-in Traits, more details can be found in the documentation

Test Suite

Related test functions can be grouped together using the @Suite macro. It can have instance variables, and you can use init and deinit to add initialization and cleanup logic.

struct VideoTests {

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

    @Test func rating() async throws {
        let video = Video(fileName: "By the Lake.mov")
        #expect(video.contentRating == "G")
    }

}

The @Suite instance initializes a new instance for each @Test method to prevent state sharing. Therefore, the above code can be rewritten as:

struct VideoTests {
    let video = Video(fileName: "By the Lake.mov")

    @Test("Check video metadata") func videoMetadata() {
        let expectedMetadata = Metadata(duration: .seconds(90))
        #expect(video.metadata == expectedMetadata)
    }

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

}

Common workflows

Runtime conditions are used to control when tests run.

.enabled(if: ...), skips the test if the condition is false.

@Test(.enabled(if: AppFeatures.isCommentingEnabled)) // Test 'videoCommenting()' skipped
func videoCommenting() {
    // ...
}

.disabled(...), can describe why the test is disabled.

@Test(.disabled("Due to a known crash"))
func example() {
    // ...
}

.bug(...), can be used to associate an issue.

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

You can use .available(...) instead of #available(...) to conditionally run tests based on OS version availability.

// Not recommend
@Test func hasRuntimeVersionCheck() {
    guard #available(macOS 15, *) else { return }
}

@Test
@available(macOS 15, *)
func usesNewAPIs() {
    // ...
}

Use .tags(...) to add tags to tests for grouping, making it easier to view them by tag in Xcode.

Parameterized testing with arguments.

// Before
struct VideoContinentsTests {

    @Test func mentionsFor_A_Beach() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "A Beach"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    @Test func mentionsFor_By_the_Lake() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "By the Lake"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    @Test func mentionsFor_Camping_in_the_Woods() async throws {
        let videoLibrary = try await VideoLibrary()
        let video = try #require(await videoLibrary.video(named: "Camping in the Woods"))
        #expect(!video.mentionedContinents.isEmpty)
        #expect(video.mentionedContinents.count <= 3)
    }

    // ...and more, similar test functions
}

// After
struct VideoContinentsTests {

    @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)
    }

}

Swift Testing and XCTest

Comparison between XCTest and Swift Testing:

For more information, see Migrating a test from XCTest.

Open Source

Cross-platform support, SPM command line, Xcode, VSCode Swift Extension.

Command line: swift test

Additional Information