Get to Know Swift Testing

At this year's WWDC, Apple unveiled Swift Testing, a new testing framework. This does not replace XCTest, which iOS developers were already using, but it enhances the introduction of our test writing practices with new features such as tagging, parameterizing testing, and more.

According to Apple, this tool is more descriptive, organized, and scalable and features Actionable failures.

❤️
Please consider supporting me by following me on LinkedIn, GitHub and Twitter.
It makes me very happy to be able to provide free content and help other developers, so I would love to connect with you.

How to Import

From Xcode 16 onwards, Swift Testing will be the default choice when creating a new test target. You should import the module Testing into the file for your tests.

Before:

import XCTest

After:

import Testing

How to Create a Test

When using XCTest, if you wanted to implement a test, you would create a function that starts with the name test. Now, you need to add the macro @Testable before declaring the function, and Xcode will automatically know that it is a test.

Before:

import XCTest

final class CodingWithVeraTests: XCTestCase {
    func test_example() {
      ...
    }
}

After:

import Testable

final class CodingWithVeraTests {
  @Test func test_example() {
    ...
  }
}
💡
The macro @Test can be async or throws and can be a global function or a method in a type.

How to Assert

Previously, when you wanted to assert an expectation, you would use XCTAssertTrue or some other variation to declare a value inside the test.

You can call the macro #expect and add a boolean condition inside.

Before:

import XCTest

final class CodingWithVeraTests: XCTestCase {
    func test_example() {
      let a: String = "Test"
      let b: String = "Another Test"

      XCTAssertEqual(a, b)
    }
}

After:

import Testable

final class CodingWithVeraTests {
  @Test func test_example() {
      let a: String = "Test"
      let b: String = "Another Test"

      #expect(a == b)
  }
}

How to Check The Test Results

One of the major selling points of Swift Testing is the detailed failure message. Before, the developer needed to add extra information in the assertion description in case it failed so it could get more details as to why it failed.

Here's an example from one of my projects.

If we create arrays or even more complex objects, it becomes even more challenging to understand what went wrong.

Now, with Swift Testing, the difference is more detailed and organized. Check this result from WWDC24: Meet Swift Testing Session.

After:

How to End a Test Earlier

Sometimes, you don't want to continue running a test if it fails after a certain point. In these cases, you likely use an XCFail with the assertion to prevent the test from continuing to run.

Now, you can use the #required macro. For this, you need to use the keyword try since it will throw an error if the assertion fails.

After:

import Testable

final class CodingWithVeraTests {
  @Test func test_example() {
      try #require(3 != 2)
      
      let a: String = "Test"
      let b: String = "Another Test"

      #expect(a == b)
  }
}
💡
You can also use this macro to unwrap values safely.

Swift Testing is an open-source software designed for Swift that embraces concurrency and macros.

Add a Display Name

To make each test more explicit, you can also add a display name for your test that will appear in the project navigator.

Before, you could only specify the test name through the method name, but now you can create an easy-to-understand name that can appear on the project navigator.

Before:

import XCTest

final class CodingWithVeraTests: XCTestCase {
    func test_testingIfTwoStringsAreEqual() {
      let a: String = "Test"
      let b: String = "Another Test"

      XCTAssertEqual(a, b)
    }
}

After:

import Testable

final class CodingWithVeraTests {
  @Test("Check if two strings are equal") func test_example() {
      try #require(3 != 2)
      
      let a: String = "Test"
      let b: String = "Another Test"

      #expect(a == b)
  }
}

Traits

You can also increase the customization by adding traits like we did before with the test name. Here are a few examples of what you can do with them:

  • Disable a test: .disabled("Description of why is disabled")
  • Reference an issue: .bug("link-example.com", "Bug name")
  • Set a time limit: .timeLimit(.minutes(10))
  • Create a tag: .tags(.nameOfTag)

As you can see, this framework allows plenty of more customization for your tests, especially with the tag feature that didn't exist before.

Why Are Tags Important

If your codebase is big, it's common to have tests with the same conditions in different files. But if you want to keep running them for whatever reason, you won't be able to do that serially. You would need to run them manually one by one.

By organizing them into tags, you can easily run a group of tests that otherwise would be difficult to run (an example would be flaky tests; you could group them by a tag and run them several times together).

Another advantage of tags is you can filter the tests in the test report by tag, and they can be shared across multiple projects and, most likely, modules.

Suites

The last and final building block for this framework is Suites. As I've said, we are no longer forced to use classes to implement tests. We can use structs, or the test can even be a global function.

But now, with the implementation of Suites, we can go even one step further and reduce the setup time for each test. These groups are related to test functions and suites; for each test you create, you will already have a suite instance associated with it to avoid state sharing.

With this, you can set a common variable before each test, and since every test function has its suite instance, it won't share state.

Example:

import Testable

struct CodingWithVeraTests {
  let a: String = "Test"
  let b: String = "Another Test"

  @Test("Check if two strings are equal") func test_example() {
      #expect(a == b)
  }

   @Test("Check if two strings are different") func test_example2() {     
      #expect(a != b)
  }
}

Parameterized Tests

Another limitation of using XCTest is that it requires repeating the same test and changing small parameters. With Swift Testing, you can add a parameter or multiple parameters and call them in the test.

Before:

import XCTest

final class CodingWithVeraTests: XCTestCase {
    func test_firstStringWithOtherStringNotEqual() {
      let a: String = "String 1"
      let b: String = "Other String"

      XCTAssertEqual(a, b)
    }

    func test_secondtStringWithOtherStringNotEqual() {
      let a: String = "String 2"
      let b: String = "Other String"

      XCTAssertEqual(a, b)
    }

    func test_thirdStringWithOtherStringNotEqual() {
      let a: String = "String 3"
      let b: String = "Other String"

      XCTAssertEqual(a, b)
    }
}

After:

struct CodingWithVeraTests {

  @Test(
    "Check if two strings are equal",
    arguments: [ "String 1", "String 2", "String 3"]
  ) func test_example(stringToTest: String) {
      #expect(stringToTest == "Other String")
  }

This allows you to easily add new parameters to the same test without the overhead of creating a new test.

Conclusion

XCTest is a robust and reliable framework many iOS developers use for their tests. During the 2024 WWDC, Apple added a new, more descriptive framework that will be the default when creating a new project starting from Xcode 16.

With all of these new super-powers the implementation of tests will be even more accessible, however, it's important to note that we can still reach most of this with XCTest.

Hope you enjoyed reading this article, let me know your thoughts!

References

Swift Testing Official Documentation: https://developer.apple.com/documentation/Testing

Meet Swift Testing: https://developer.apple.com/videos/play/wwdc2024/10179/

WWDC24 Platform State of the Union: https://developer.apple.com/videos/play/wwdc2024/102/

Official GitHub:
https://github.com/apple/swift-testing