Swift Testing: Getting Started

In 2021, Apple released Swift concurrency to an adoring audience — finally, developers could write Swift code to implement concurrency in Swift apps! At WWDC 2024, developers got another game changer — Swift Testing. By Audrey Tam.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Parameterized Test With Two Arguments

You can pass any sendable collection — including array, dictionary, enum, range — as an argument to a test function, and the testing library will pass each element in the collection, one at a time, to the test function as its first and only argument. The tests run in parallel while maintaining thread safety.

You're not restricted to a single argument. Here's an example from Go further with Swift Testing:

enum Ingredient: CaseIterable {
    case rice, potato, lettuce, egg
}

enum Dish: CaseIterable {
    case onigiri, fries, salad, omelette
}

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

Apple engineers seem to spend a lot of time thinking about food! OK, it's time to get down to some basic building blocks.

Swift Testing Building Blocks

Swift Testing has four building blocks: test functions, expectations, traits, and suites. These are designed to feel Swifty:

  • Test functions integrate seamlessly with Swift concurrency by supporting async/await and actor isolation.
  • Expectations accept all the built-in language operators and can also use async/await.
  • Both expectations and traits leverage Swift macros, allowing you to see detailed failure results and specify per-test information directly in code.
  • Suites embrace value semantics, encouraging the use of structs to isolate state.

@Test Functions

You've already tried several @Test functions. You can also drag in a @Test code snippet from the Library.

Expectations

You've also used the #expect macro, which replaces all the XCTAssert methods. A powerful variant is the
#require macro — the test fails if a required expectation fails. Here are some examples from Meet Swift Testing:

try #require(session.isValid)
session.invalidate()
 
let method = try #require(paymentMethods.first)
#expect(method.isDefault)

Failure throws an error, so you always try a required expectation.

Halting a Test After Failure

Swift Testing uses #require to give you much more control over handling failure, compared to XCTest:

  • In XCTest, you set continueAfterFailure to false, then any subsequent assertion that fails halts the test.
  • In Swift Testing, you can use #require instead of #expect to make an expectation required, and it will throw an error if it fails. This lets you choose which expectations should halt the test.
// XCTest
func testExample() {
  self.continueAfterFailure = false
  // other assertions, one of which fails
  XCTAssertEqual(x, y) // test halts even if this assertion succeeds
}

// Swift Testing
@Test func example() throws {
  // other expectations, some of which fail and throw errors
  try #require(x == y)  // the test halts only if this fails
}

When You Want an Expectation to Fail

This section uses more code from Go further with Swift Testing.

Suppose you expect something to fail. Instead of writing your own do-catch code:

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

Use #expect(throws:) in a test that should throw an error. The error can be general or specific. If the test throws that error, it's a successful test.

@Test 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!
    }
}

If you know a test will fail due to a known issue that can't be fixed immediately, use withKnownIssue:

@Test func softServeIceCreamInCone() throws {
    withKnownIssue {
        try softServeMachine.makeSoftServe(in: .cone)
    }
}

If the function returns an error, the test’s results doesn't count toward a test failure, because this failure is expected. Instead, the test appears as an expected failure in the results and doesn't add noise that might distract your attention from new problems.

You can run other testing code around the known-issue code.

@Test func softServeIceCreamInCone() throws {
    let iceCreamBatter = IceCreamBatter(flavor: .chocolate)
    try #require(iceCreamBatter != nil)
    #expect(iceCreamBatter.flavor == .chocolate)

    withKnownIssue {
        try softServeMachine.makeSoftServe(in: .cone)
    }
}

@Suite

A test suite is a group of related tests. Suites may be denoted explicitly by the @Suite attribute, but it's implicit for any type that contains test functions or nested suites. It's required only when you specify a display name or other trait. Here's a code example from Meet Swift Testing:

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

}

Suites can have stored instance properties. They can use init or deinit to perform logic before or after each test. And a separate @Suite instance is created for every instance @Test function it contains to avoid unintentional state sharing.

Traits

Traits can do several things:

  • Add descriptive information about a test.
  • Customize when or whether a test runs.
  • Modify how a test behaves.

Here are some specific traits:

  • @Test("Custom name"): Customize the display name of a test. This name appears in the Test navigator and other places in Xcode.
  • @Test(.bug("example.com/issues/99999", "Title")): Reference an issue from a bug tracker.
  • @Test(.tags(.critical)): Add a custom tag to a test.
  • @Test(.enabled(if: Server.isOnline)): Specify a runtime condition for a test.
  • @Test(.disabled("Currently broken")): Unconditionally disable a test.
  • @Test(...) @available(macOS 15, *): Limit a test to certain OS versions.
  • @Test(.timeLimit(.minutes(3))): Set a maximum time limit for a test.
  • @Suite(.serialized): Run the tests in a suite one at a time, without parallelization.

Controlling When Tests Run

Use the .enabled(if:) trait to run a test when a condition is true. Use .disabled() if you want a test to never run. Instead of commenting out a test function, disabling it lets the compiler check its code. You can include a comment to explain the reason why the test is disabled, and comments appear in the structured results, so your CI system can show them. If a test is disabled because of an issue tracked in a bug-tracking system, you can include a .bug(...) trait to reference related issues with a URL. Then, you can see that bug trait in the Test Report and click to open its URL.

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

When the entire body of a test can run only on certain OS versions, place the @available(...) attribute on that test instead of checking at runtime using #available. This tells the testing library that the test has an OS version condition, so the results contain more accurate information.

// ❌ Avoid checking availability at runtime using #available
@Test func hasRuntimeVersionCheck() {
    guard #available(macOS 15, *) else { return }

    // ...
}

// ✅ Prefer @available attribute on test function
@Test
@available(macOS 15, *)
func usesNewAPIs() {
    // ...
}

Contributors

Adriana Kutenko

Illustrator

Gina De La Rosa

Team Lead

Over 300 content creators. Join our team.