New Approaches in Swift for Testing: Analysis of Swift Testing Technology

Image

For years, testing in Swift has been a mix of XCTest, third-party libraries, and custom solutions. That all changed at WWDC 24 with the introduction of Swift Testing, a modern framework from Apple that completely rethinks the testing process.

In this article, we'll explore the powerful new capabilities of Swift Testing and how it improves upon its predecessor.

An Introduction to Swift Testing

Swift Testing is a new unit testing framework from Apple, unveiled at WWDC 24 as a modern replacement for the classic XCTest. It's open-source and designed from the ground up to leverage the latest features of the Swift language.

Image

The framework supports modern Swift features like macros and Swift Concurrency. It's also cross-platform, running everywhere Swift does (macOS, iOS, Linux, Windows), which is a game-changer for server-side development and building universal tools.

However, the framework currently has some limitations. It doesn't yet support UI tests, performance testing, or Objective-C code. Despite this, Swift Testing is already a compelling alternative to XCTest thanks to its powerful new features.

Key Features of Swift Testing

The @Test Macro

Swift Testing introduces the @Test macro, which simplifies test creation. You no longer need to create a class that inherits from XCTestCase; simply annotate any function with @Test to turn it into a test.

import Testing

@Test
func foodTruckExists() {
    // Test logic goes here.
}

You can also provide a custom display name for better readability and add modifiers like @MainActor or async throws directly to the test function.

@Test(Food truck exists)
@MainActor
func foodTruckExists() async throws {
    // Test logic goes here.
}

Simplified Test Lifecycle

Unlike XCTest, where tests are tied to classes, Swift Testing allows you to use both classes and structures. This simplifies the test lifecycle: instead of overriding setUp and tearDown methods, you can now use standard initializers (init) and deinitializers (deinit) for setup and cleanup logic.

Grouping Tests with @Suite

Alongside @Test, the framework includes the @Suite macro to group related tests into a collection. Using suites, you can:

  • Group related tests into a single logical unit.
  • Nest suites to create a clear test hierarchy.
  • Apply modifiers (Traits) to all tests within a suite at once.
  • Customize the display name in Xcode and test reports.
@Suite(Arithmetic)
struct ArithmeticTests {
    let calc = Calculator()
    
    @Test
    func addition() {
        let result = calc.addition(num1: 2, num2: 3)
        #expect(result == 5)
    }

    @Test
    func subtraction() {
        let result = calc.subtraction(num1: 5, num2: 3)
        #expect(result == 2)
    }
}

Modern Assertions with #expect and #require

Swift Testing replaces traditional XCTAssert functions with a flexible macro-based approach. The primary tool is the #expect() macro, which checks if a condition is true.

A key advantage of #expect() is that it records a failure but allows the test to continue running. This enables you to see all failing assertions in a single test run instead of stopping at the first one.
Even though the first two checks fail, the test continues to the end.
Even though the first two checks fail, the test continues to the end.

For situations where a failure should immediately stop the test, you can use the #require() macro. If its condition is false, the test terminates by throwing an error. Additionally, #require() can be used to unwrap optionals, serving as a modern replacement for XCTUnwrap from XCTest.

@Test
func stringParsesToInt() throws {
    let data = 2
    let parsed = try #require(Int(data))
    #expect(parsed == 2)
}

Customizing Tests with Traits

Traits are modifiers that add metadata or change the behavior of tests and suites. You can use them to conditionally enable or disable tests, add descriptive tags, set time limits, and much more.

Conditional Execution and Disabling

You can easily disable a test or an entire suite using the .disabled() trait, with an optional reason for documentation. You can also use the .enabled(if:) trait to run tests only when a specific condition is met. These can be combined for more complex logic.

@Test(Food truck sells burritos, .disabled(We only sell Thai cuisine))
func sellsBurritos() async throws {
// ...
}

enum Season { case winter, spring, summer, autumn
  static var current: Season = .summer
}

@Test(Ice cream is cold, .enabled(if: Season.current == .summer))
func isCold() async throws {
    // ...
}

Tagging Tests for Organization

Swift Testing introduces a powerful tagging system. You can define custom tags and apply them to tests and suites for better organization, making it easy to filter and run specific groups of tests.

// 1. Define custom tags
extension Tag {
    @Tag static var networking: Tag
    @Tag static var json: Tag
}

// 2. Apply tags to tests and suites
@Suite(.tags(.networking))
struct NetworkLayerTests {
    @Test(Decode User from JSON, .tags(.json))
    func decodeUser() {
        // ...
    }
}

Linking to Bug Reports

You can link a disabled test directly to an issue in a bug tracking system. The .bug() trait lets you add a URL, and Xcode provides a button to open the link directly from the test report.

@Test(.disabled(), .bug(https://my-jira-instance.com/browse/PROJ-123))
func testFailingFeature() throws { ... }
A direct link to the bug report in Xcode.
A direct link to the bug report in Xcode.

Parameterized Tests

One of the most significant new features is support for parameterized tests. This allows you to run the same test logic multiple times with different input values, which is ideal for verifying functionality across a range of cases without writing repetitive code.

Instead of using a for loop inside a test, you can pass a collection of inputs directly as an argument to the @Test macro. The test function then receives each value as a parameter, and Swift Testing runs the test once for each item.

enum Food: CaseIterable {
    case burger, iceCream, burrito, noodleBowl, kebab
}

@Test(All foods available, arguments: Food.allCases)
func foodAvailable(_ food: Food) async throws {
    let foodTruck = FoodTruck(selling: food)
    #expect(await foodTruck.cook(food))
}

Parameterization also works with multiple arguments. To test corresponding pairs of values from multiple collections, use the zip() function to combine them into a single sequence of tuples. Otherwise, the framework will test every possible combination.

@Test(arguments: zip([18, 30, 50, 70], [77.0, 73, 65, 61]))
func verifyNormalHeartRate(age: Int, bpm: Double) {
    let hr = HeartRate(bpm: bpm)
    let context = HeartRateContext(age: age, activity: .regular, heartRate: hr)
    #expect(context.zone == .normal)
}

Migrating from XCTest to Swift Testing

Let's look at a few common scenarios to see how you can migrate your existing XCTest code to the new framework.

Example 1: Consolidating Repetitive Tests

If you have multiple tests that differ only by input values, parameterized tests are the perfect solution. You can merge them into a single, cleaner test by passing the varying parameters as arguments.

Before: XCTest

final class OKPerfomanceModuleTests: XCTestCase {
    // ...
    func testBeginChildCallNoMessage() throws {
        let scenario = test-scenario-1
        // ... assertions
    }
    
    func testBeginChildCallWithMessage() throws {
        let scenario = test-scenario-2
        let message = test-message
        // ... similar assertions
    }
}

After: Swift Testing

struct Argument {
    let scenario: String
    let message: String?
}

final class OKPerfomanceModuleTests {
    @Test(arguments: [
        Argument(scenario: test-scenario-1, message: nil),
        Argument(scenario: test-scenario-2, message: test-message),
    ])
    func beginScenario(arg: Argument) {
        // ... single set of assertions using arg
        #expect(collector.scenario == arg.scenario)
        #expect(collector.message == arg.message)
    }
}

Example 2: Migrating from XCTUnwrap

The XCTUnwrap function can be directly replaced with the more expressive #require() macro, which serves the same purpose of unwrapping an optional or throwing an error.

Before: XCTest

func testResponseSerializerFailsDataIsNil() throws {
    // ...
    let networkError = try XCTUnwrap(result.failure?.asNetworkingError)
    XCTAssertEqual(networkError.isInputDataNilOrZeroLength, true)
}

After: Swift Testing

@Test
func responseSerializerFailsDataIsNil() throws {
    // ...
    let networkError = try #require(result.failure?.asNetworkingError)
    #expect(networkError.isInputDataNilOrZeroLength)
}

Swift Testing is a modern, intuitive framework that significantly simplifies the work of developers and testers. With powerful features like Swift Concurrency support, parameterized tests, and extensive customization through traits, it marks a major step forward for the Swift ecosystem.

While still new, it's a promising replacement for XCTest that is well worth exploring for your next project.