Neil Macy

An Introduction to Swift Testing

Swift Testing is the new framework for unit testing in Swift. It is shipped as the default in Xcode 16 (now in beta), and it looks great.

Watch the session video from WWDC 2024 for the official intro from Apple.

Swift Testing is an open source framework hosted on GitHub. You can read much more about it in the main project, and also in the Vision Document.

The @Test Attribute

There are four main building blocks in Swift Testing, and the first is the @Test attribute. In XCTest you needed a test prefix on your method name to tell the framework that this method is a test. The @Test attribute does that now, so your test function can have any name.

A test can also now be global. In XCTest, tests had to belong to a subclass of XCTestCase. (Talking of which, we'll get to test suites soon.)

import Testing

@Test func myGlobalFunction() { // function name doesn't start with `test`
    #expect(true) // we'll get to this next
}

Expectations

The second building block is Expectations. These are equivalent to assertions in XCTest, but while XCTest has lots of different assertions (XCTAssert, XCTAssertTrue, XCTAssertEqual, XCTAssertNotNil etc.), but Swift Testing has just two: #expect and #require.

The #expect macro lets you specify any condition, using the standard Swift language features. If the condition evaluates to true, the test passes.

The #require macro also validates a condition, but in this case, if the condition evaluates as false, the test will terminate immediately. #require also throws an exception, so you need to call it with try.

One tool of XCTest that I use a lot is XCTUnwrap, to safely unwrap an optional, and fail the test if the optional can't be unwrapped. You can use #require the same way:

let unwrappedValue = try #require(myOptionalValue)

If the test fails, then not only will it fail, it will also generate an error message using the evaluated expression, to help you see why it failed. This is a huge difference from having to generate failure messages manually in XCTest.

Traits

The third building block of Swift Testing is Traits. Traits let you add details to a test, for example a display name, a bug ticket reference, or a condition under which the test should run (e.g. only in a certain language, or only when a certain feature flag is enabled).

This example shows enabling a test only when a feature is enabled. (It's taken directly from the Swift Testing README.)

@Test(.enabled(if: AppFeatures.isCommentingEnabled))
func videoCommenting() async throws {
    let video = try #require(await videoLibrary.video(named: "A Beach"))
    #expect(video.comments.contains("So picturesque!"))
}

Tags

You can also use Traits to organise tests into tags, and you can group tests by tag rather than suite or file in the Xcode sidebar. I love this idea for small projects that have a limited number of people working on them. I know it’ll be misused in bigger teams, with lots of different tags being semantically the same.

Parameterised Tests

It can be common to run one test multiple times, changing the parameters to test different conditions. Traits let you simplify that logic, by defining your arguments outside of the test. You can then see the different test runs in the sidebar in Xcode, and even re-run specific arguments, while only having one test defined.

Here's an example, again taken from the Swift Testing README:

@Test("Continents mentioned in videos", arguments: [
    "A Beach",
    "By the Lake",
    "Camping in the Woods"
])
func mentionedContinents(videoName: String) async throws {
    let videoLibrary = try await VideoLibrary()
    let video = try #require(await videoLibrary.video(named: videoName))
    #expect(video.mentionedContinents.count <= 3)
}

This runs the test against three different videoName values: "A Beach", "By the Lake" and "Camping in the Woods". You'll see each of these in the sidebar as different tests, but you only need to write one @Test function.

Test Suites

Test Suites still exist, and they’re the fourth building block of Swift Testing. Instead of a subclass of XCTestCase, you can keep it really simple and embed your tests in a struct. That alone creates a Suite, implicitly.

struct MyTests {
    @Test func myTest() {
        #expect(true)
    }
}

MyTests in the example above is a Test Suite. This is inferred from the presence of a @Test.

You can also use the @Suite annotation. One reason why you might do that explicitly is to apply Traits that should be inherited by all tests. For example, if you apply a Tag to a Suite, that Tag applies to all tests in the Suite.

Suites can be a struct, actor or class, but struct is encouraged. This means you get the benefit of value semantics; each test will get its own instance of the Suite. That means you don't risk sharing data between tests and corrupting the tests.

One reason you may use choose to use a class or actor, however, is if you need a deinit method. In XCTest, you had setUp and tearDown, and multiple examples of each, depending on whether you wanted it to be run on a class or instance level, or if it should be async. There's only one init and deinit in Swift Testing, and deinit isn't available in structs.

Wrap Up

XCTest isn't going anywhere any time soon. You can take any migration slowly, and use both XCTest and Swift Testing in the same test target. And Apple explicitly mentions that XCTest is still required for performance and UI testing.

But Swift Testing looks like a great improvement on unit testing, and I'm excited to try it out.

Published on 11 June 2024