DEV Community

Cover image for Introduction to Swift Testing: Apple's New Testing Framework
Raphael Martin
Raphael Martin

Posted on

Introduction to Swift Testing: Apple's New Testing Framework

Introduction

In September 2024, Apple released the macOS Sequoia 15, alongside Xcode 16. This new version of the IDE brings some new cool stuff, such as Swift 6 and the open-source unit testing framework Swift Testing, the latter being the subject of today's post.
The motivation behind introducing a new native way of writing tests in Swift is to overcome limitations of XCTest. Let's explore how Swift Testing tries to achieve it, and understand where and specially when you should start using it.


What Problems Does Swift Testing Solve?

Verbose and Boilerplate-heavy Syntax in XCTest

When creating tests with XCTest, a lot of boilerplate is needed in order to create any kind of test, even the simplest ones:

import XCTest

final class SumTest: XCTestCase {
    var calculator: Calculator?

    override func setUpWithError() throws {
        // Instantiate the needed objects
        calculator = Calculator()
    }

    override func tearDownWithError() throws {
        // Deallocate objects to free space in DevOps environments
        calculator = nil
    }

    func testSum() throws {
        // Unwraps optional value
        let calculator = try XCTUnwrap(self.calculator)

        // Assert that the `sum` function calculates correctly
        XCTAssertEqual(calculator.sum(number1: 2, number2: 2), 4)
    }
}
Enter fullscreen mode Exit fullscreen mode

With Swift Testing, your test cases can be global functions, excluding the need of it placing it inside any kind of scope:

import Testing

@Test func sum() {
    let calculator = Calculator()

    #expect(calculator.sum(number1: 2, number2: 2) == 4)
}
Enter fullscreen mode Exit fullscreen mode

Let's break down the piece of code above:

  1. In the first line, we're importing the Testing package, that is the Swift Testing framework package. That allow us to use the @Test property wrapper and the #expect macro.
  2. In the sum function declaration, we are adding the @Test wrapper. That makes Xcode understand that this function is a Test Case, and allows us to execute the test.
  3. The #expect macro is very similar to XCTest XCTAssert() function. But differently from there, there's no variations such as XCTAssertEquals, XCTAssertNil, and etc. The reason for that is to simplify and improve semantics: reading expect 2 + 2 to be equals 4 is more "human" natural than assert equals: 2 + 2, 4. More about this topic in the next section.

Assertion semantics

As we previously saw, the #expect macro works similar to XCTAssert function. Like the older function, the new macro has a similar behavior: when an expectation fails, the test continues executing until it completes or throws an error:

struct Person {
    var name: String = ""

    init(name: String) {
        // missing property setting here
    }
}

@Test func example() {
    let person: Person? = Person(name: "John")

    #expect(person?.name == "John") // Here you'll see a failing expectation
    #expect(person != nil) // Swift Test will continue to verifying the expectations, and will pass this one
}
Enter fullscreen mode Exit fullscreen mode

If you need to stop test execution when a specific expectation is not met, you can use the #require macro. Unlike #expect, this is a throwing function, which requires a try statement when calling it. It can also return a value when receiving an optional as argument.

@Test func example() throws {
    let person: Person? = Person(name: "John")

    try #require(person?.name == "John") // Here the execution will stop after failing the requirement

    #expect(person != nil)
}
Enter fullscreen mode Exit fullscreen mode

Note that the testing function now needs to be annotated with throws since we are using a try statement, and now it will stop the execution when not matching a requirement.
The #require macro can also be used to unwrap optional values, failing the test if the value is nil, similar to what XCTUnwrap does:

@Test func example() throws {
    let company = companyFactory.build()

    // Company's owner is an Optional type. If its value is `nil`, the test will fail pointing exactly why
    let owner = try #require(company.owner)

    #expect(owner.name == "John")
}
Enter fullscreen mode Exit fullscreen mode

Naming, conditional tests and behaviors

Giving meaningful names to your tests

It is a known good practice to give rich and descriptive names to your tests. That's because when reading a report, it's easier to identify what exactly in your app is not right when tests are failing:

import XCTest

final class ExampleTests: XCTestCase {

    func testThatWhenSearchingWithAirplaneModeOnErrorMessageShouldAppear() throws {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Even though it's a good practice, these big function names can be difficult to read in a codebase.
With Swift Testing you can solve this problem by setting the displayName attribute in the @Test macro:

@Test("Error message is displayed when searching with airplane mode on")
func noInternetMessageOnSearch() throws {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This is how you'll see each of the tests in the Xcode Test Navigator:

XCTest vs Swift Testing tests names

Additionally, Swift Testing eliminates the need of starting your function with the test prefix, giving you more flexibility.

Conditionals

During the software development process, it's not uncommon to have tests that you temporarily don't want them to be executed. In XCTest you don't have any specific tool to make a single test case not being ran, so you need to use strategies as commenting code, or adding a return to the very first line of the test case, for example.

import XCTest

final class ExampleTests: XCTestCase {

    func testUnfinishedFeature() throws {
        // This feature is not completed yet and the test is failing
        return

        // Test implementation here
    }
}
Enter fullscreen mode Exit fullscreen mode

With Swift Testing, you can use Traits to handle this kind of situation in a more elegant way. Let's explore some of them:

1. .enabled(if: Bool)

You can use this Trait to create conditional logic that decides whether the test case should or shouldn't be executed:

@Test("New Screen loads correctly", .enabled(if: Features.newScreenEnabled))
func newScreen() throws {
    // Test implementation
}
Enter fullscreen mode Exit fullscreen mode

In this case above, we have an unfinished feature, and we already have a test case for it (if you're following TDD for example, that's very common). If you want to merge your code in a stable branch, but don't want the unfinished test case to be run in the DevOps environment, the enabled(if:) trait can be very useful.

2. .disabled()

With the .disabled() trait you can simply disable a test. This is useful when you have a failing test that isn't a priority to fix right away, so you can disable to your tests pass, and then fix it later:

@Test(.disabled()) func myMinorFailingTest() throws {
    // Test implementation
}
Enter fullscreen mode Exit fullscreen mode

The .disabled() function also accepts a String argument to add a comment about why you're disabling this test, and it's very recommendable to use it:

@Test(.disabled("This is affecting less than 1% of the users, will be fixed in the next release"))
func myMinorFailingTest() throws {
// ...
Enter fullscreen mode Exit fullscreen mode

Another excellent practice you could follow when disabling tests, is using the .bug() trait. This one is used to identify a known bug that will be addressed in future, and is already reported in some bug tracker tool:

@Test(
  .disabled("Affecting few users"),
  .bug("github.com/MyProjectRepo/issues/99999", "Incorrect message being displayed")
)
func myMinorFailingTest() throws {
Enter fullscreen mode Exit fullscreen mode

Running your tests with a disabled test case like that should display to you a successful output:

◇ Test run started.
↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
◇ Suite ExampleTests started.
◇ Test example() started.
​✘ Test myMinorFailingTest() skipped: "Affecting few users"
​✔ Test example() passed after 0.001 seconds.
✔ Suite ExampleTests passed after 0.001 seconds.
✔ Test run with 2 tests passed after 0.001 seconds.
Enter fullscreen mode Exit fullscreen mode

Look that the myMinorFailingTest() appears there as a skipped test, along the reason for it.

3. @available(...)

Another reason to avoid running tests is having features that requires specific OS versions, in a moment that you cannot guarantee that all the environments that run tests already uses that version. For this scenario, instead of a Trait, you can use a different macro with the @Test one:

@Test
@available(macOS 15, *)
func useNewAPIs() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Using this macro is better than, for example, adding a guard #available(...) else to your test implementation. That's because the @available macro will not check the OS availability in runtime, as the guard statement, and you'll have clearer logs and test outputs.

Behaviors

Another challenge you may have while creating tests is creating multiple similar test cases. That happens because sometimes you have an algorithm that, depending on the input, it may create different results, so you decide to test all the different possible inputs:

final class ExampleTests: XCTestCase {
    func testThatColorHelperCorrectlyDecodesFromRGB() throws {
        let pattern = "RGB(255, 0, 0)"
        let color = ColorHelper.from(string: pattern)
        XCTAssertEqual(color, .red)
    }

    func testThatColorHelperCorrectlyDecodesFromCMYK() throws {
        let pattern = "CMYK(0, 100, 100, 0)"
        let color = ColorHelper.from(string: pattern)
        XCTAssertEqual(color, .red)
    }

    func testThatColorHelperCorrectlyDecodesFromHSV() throws {
        let pattern = "HSV(0, 100, 100)"
        let color = ColorHelper.from(string: pattern)
        XCTAssertEqual(color, .red)
    }

    func testThatColorHelperCorrectlyDecodesFromHSL() throws {
        let pattern = "HSL(0, 100, 50)"
        let color = ColorHelper.from(string: pattern)
        XCTAssertEqual(color, .red)
    }

    func testThatColorHelperCorrectlyDecodesFromHex() throws {
        let pattern = "#FF0000"
        let color = ColorHelper.from(string: pattern)
        XCTAssertEqual(color, .red)
    }
Enter fullscreen mode Exit fullscreen mode

With several tests like that, if you need to change anything in the ColorHelper, you'll need to update all your test cases. Maybe you're thinking that you could optimize the code above with a forEach:

func testThatColorHelperCorrectlyDecodesFromAllPatterns() throws {
    [
        "RGB(255, 0, 0)",
        "CMYK(0, 100, 100, 0)",
        "HSV(0, 100, 100)",
        "HSL(0, 100, 50)",
        "#FF0000"
    ].forEach {
        let color = ColorHelper.from(string: $0)
        XCTAssertEqual(color, .red)
    }
}
Enter fullscreen mode Exit fullscreen mode

The problem with this approach is that when failing, you don't know which pattern is making the ColorHelper not decode the color properly, so you need to debug and investigate it. Also, after adding a possible a fix to it, you'll need to re-run the test for all inputs. If your test case perform some heavy task, you may need to wait for a long time to see if your fix works.

Swift Testing then brings the parameterized tests, a way to optimize this situation. To use it, you need to use the arguments Trait, and you also need to add an argument to your test function, like that:

@Test(arguments: [
    "RGB(255, 0, 0)",
    "CMYK(0, 100, 100, 0)",
    "HSV(0, 100, 100)",
    "HSL(0, 100, 50)",
    "#FF0000"
])
func colorHelper(pattern: String) {
    let color = ColorHelper.from(string: pattern)

    #expect(color == .red)
}
Enter fullscreen mode Exit fullscreen mode

When running this test, you see the following result in Xcode:

Swift Testing failing result

It shows us that the test is passing for all arguments, except "#FF0000". So we can analyze the ColorHelper.from function and investigate why. After finding and fixing the problem, you can re-run the test only for that argument:

How to re-run test for a single parameter

After seeing a successful result, you can run it for all arguments again, and then ship your code.

Performance

There's yet another advantage of using parameterized tests: Swift Testing uses Swift Concurrency to execute tests with different arguments in parallel, optimizing resources and time.

Hierarchy

Suites

Even though it's not needed, having your tests inside some scope is possible with Swift Testing. These scopes are called Suites. For creating one, you just need to add your test case functions to a class, struct or even an actor. But you can also explicitly add a @Suite macro, that we'll cover when it's useful more ahead. Let's see a simple example with a struct:

struct CalculatorTests {
    let calculator = Calculator()

    @Test func testSum() {
        #expect(calculator.sum(number1: 2, number2: 3) == 5)
    }

    @Test func testMultiply() {
        #expect(calculator.multiply(number1: 2, number2: 3) == 6)
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, CalculatorTests is a Suite. Doing it allows you making two improvements: execute all tests inside the Suite at once, and sharing dependencies across different test cases (in this case the Calculator object) through stored properties, as you would do with XCTest. The difference here, with the possibility of adding them to a struct, you have an improvement in memory management, since structs are deallocated in the moment they get out of scope, different from reference types. I have a dedicated post about how Swift handles memory here, you can read it to go further in this subject.

Suites in Swift Testing can have stored properties, as we saw in our example, and they can also use init and deinit (this last only in reference types) to perform needed tasks before and after each test case (similar to XCTest setUp and tearDown functions). This part is important: for each test case, a new Suite is instantiated, avoiding two tests sharing a same state.

actor SuiteWithTwoTests {
    init() {
        print("SuiteWithTwoTests was initialized")
    }

    deinit {
        print("SuiteWithTwoTests was deinitialized")
    }

    @Test func testOne() {
        #expect(true)
    }

    @Test func testTwo() {
        #expect(true)
    }
}
Enter fullscreen mode Exit fullscreen mode

Output of the tests above:

◇ Test run started.
↳ Testing Library Version: 102 (arm64-apple-ios13.0-simulator)
◇ Suite SuiteWithTwoTests started.
◇ Test testOne() started.
◇ Test testTwo() started.
SuiteWithTwoTests was initialized
SuiteWithTwoTests was initialized
SuiteWithTwoTests was deinitialized
SuiteWithTwoTests was deinitialized
✔ Test testOne() passed after 0.001 seconds.
✔ Test testTwo() passed after 0.001 seconds.
✔ Suite SuiteWithTwoTests passed after 0.001 seconds.
✔ Test run with 2 tests passed after 0.001 seconds.
Enter fullscreen mode Exit fullscreen mode

Swift Testing allows you to create nested hierarchy as well, by creating scopes inside other scopes:

struct DateTests {
    @Test func formatting() {
        // ...
    }

    struct Coding {
        @Test func encodeDate() {
            // ...
        }

        @Test func decodeDate() {
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With this nested Suites, you can execute only the inner ones by executing the Coding suite, if you want, or maybe run all tests at once by executing the DateTests.

Tags

Suites are not the only way for grouping your test cases with Swift Testing. You can also have tags: it allows you adding a same tag to multiple tests in different Suites. For that, you should first create a tag, extending the Testing.Tag struct, adding a static property with the @Tag property wrapper:

extension Tag {
    @Tag static var formatting: Tag
}

struct DateTests {
    @Test(.tags(.formatting)) func formatting() {
        // ...
    }
}

struct CurrencyTest {
    @Test(.tags(.formatting)) func formatting() {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

With tags, you can execute all the tests inside a tag at once:

How to filter tests by tags

Swift Testing also allows you to add tags to an entire Suite. For that, you need to use the @Suite macro:

struct DateTests {
    @Test func formatting() {
        #expect(true)
    }

    @Suite(.tags(.coding))
    struct Coding {
        @Test func encodeDate() {
            #expect(true)
        }

        @Test func decodeDate() {
            #expect(true)
        }
    }
}

struct CurrencyTest {
    @Test func formatting() {
        #expect(true)
    }

    @Suite(.tags(.coding))
    struct Coding {
        @Test func encodeMoney() {
            #expect(true)
        }

        @Test func decodeMoney() {
            #expect(true)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

With the code above, we are telling Swift Testing that all the test cases of DateTests.Coding are tagged coding, such as all the CurrencyTests.Coding ones. This way, Xcode allows us to run all the encode/decode tests, without running the formatting ones:

Running all tests in a tag


Comparison: Swift Testing vs. XCTest

Let's recap all the differences between Swift Testing and XCTest.

Discovery

  • XCTest: Name begins with "Test"
  • Swift Testing: @Test macro

Supported types

  • XCTest: instance methods of a XCTestCase class object
  • Swift Testing: instance methods of classes, structs or actors, static methods and global functions

Traits

  • XCTest: not supported
  • Swift Testing: used to conditionally enable or disable test cases, track known bugs (either per-test or per-suite) and run parameterized tests

Parallel execution

  • XCTest: Launch multi process in macOS or simulators
  • Swift Testing: Uses Swift Concurrency, running it in-process and supports physical devices such as iPhones and Apple Watches

Continuity after failing

  • XCTest: By default, it stops execution on failure, but allows you to control this behavior by changing the continueAfterFailure property value
  • Swift Testing: Have assertion functions with different behaviors, with #expect continuing after failing and #require stopping the execution

Migrating to Swift Testing

If you liked Swift Testing so far, you can already start using it in your project, either creating the new test cases in the new framework or refactoring the existing ones into it.

If your project doesn't support unit tests yet, to start using Swift Testing you should add a Testing Target to it. For that, in Xcode go to File > New > Target, select your platform and then "Unit Testing Bundle". Make sure not to select "UI Testing Bundle":

Unit Testing Bundle target template on Xcode

In the next screen, fill in your project info and select Swift Testing as your "Testing System". In Xcode 16 it will be the default.
In the new folder that will be created with the same name as your target, an example test file will be created. You can add more test files to this folder and start creating your Swift Testing test cases.

If you already support Unit Tests in your project with XCTest, this step is not needed: you can add Swift Testing tests to the same target you already have.

To create your first test, just create a new file, usually with the "Tests" suffix, import Testing and start adding test cases to it:

import Testing

@testable import MyAppModule // <- Replace here

@Test("Calculator sum function sums two numbers correctly") func sum() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Importing your app's module is important so the testing target can have visibility into your app's code. Replace "MyAppModule" with the name of your app's module. The @testable macro is also important, so the testing target can have visibility to internal properties/functions.

When rewriting XCTest tests to Swift Testing, Apple recommends creating global @Test functions when you have XCTest classes with only one test case, instead of creating a Suite with one single test:

Convert from:

// DateParserTests.swift
import XCTest

final class DateParserTests: XCTestCase {
    override func setUpWithError() throws {
        // ...
    }

    override func tearDownWithError() throws {
        // ...
    }

    func testThatDateIsCorrectlyParsed() throws {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

To:

// DateParserTest.swift
import Testing

@Test("Date is correctly parsed") func parseDate() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Limitations

Even though Swift Testing brings several new cool features, some limitations should be considered before adopting it.

Swift Testing is available only for projects written in Swift 6 using Xcode 16. Also, it doesn't support UI automation(XCUIApplication) neither performance API (XCTMetric) yet, so for these kind of tests you should still using XCTest.

Swift Testing, as the name suggests, works only with Swift, so it does not support testing code written in Objective-C.


Running from Terminal

Swift Testing is highly integrated with Swift Package Manager (SPM). When creating a SPM package, you can easily run the package tests from terminal, with the following command:

swift test
Enter fullscreen mode Exit fullscreen mode

Then, you should see the results like this:

[13/13] Linking LibWithSwiftTestingPackageTests
Build complete! (45.55s)
Test Suite 'All tests' started at 2025-01-31 11:30:49.785.
Test Suite 'All tests' passed at 2025-01-31 11:30:49.795.
     Executed 0 tests, with 0 failures (0 unexpected) in 0.000 (0.010) seconds
◆  Test run started.
⮑ Testing Library Version: 102 (arm64e-apple-macos13.0)
◆  Test example() started.
✅ Test example() passed after 0.001 seconds.
✅ Test run with 1 test passed after 0.001 seconds.
Enter fullscreen mode Exit fullscreen mode

Conclusion

Swift Testing is a cool new framework, and today we explored its features and differences from the existing XCTest framework for unit testing. More than just learn how to use a new technology, it's important to understand why it exists and which problems it solves. Also, it's important to understand pros and cons to evaluate whether migrating makes sense.
In general, Swift Testing brings far more advantages than disadvantages in my opinion, and I'm excited for a future where it is the default testing platform for most projects.

Also, Swift Testing is an open-source project, so you can fork its repo and start contributing yourself!


Additional Resources

Top comments (0)