Unlocking New Possibilities: A Comprehensive Guide to Swift Testing

👍
· Oct 23, 2024 · 8 mins read
Unlocking New Possibilities: A Comprehensive Guide to Swift Testing

As iOS development continues to evolve, the tools and frameworks available to developers have also seen significant advancements. One such innovation is the introduction of Swift Testing, a modern approach to writing tests in the Swift programming language. In this guide, we’ll dive into the key features and advantages of Swift Testing, helping you elevate your testing practices and streamline our iOS development workflow.

What is Swift Testing?

Swift Testing is a new testing framework that was introduced with the release of Xcode 16. It offers a fresh perspective on writing tests by leveraging the power of Swift’s syntax and language features. Unlike the traditional XCTest framework, which requires you to inherit from the XCTestCase class, Swift Testing introduces a more concise and expressive approach using the @Test macro.

Key Features of Swift Testing

1. Macro-Based Assertions:

Swift Testing simplifies the assertion syntax by using the #expect() macro instead of the traditional XCTAssert() methods. This allows for more readable and self-documenting test cases.

struct StringTests {  
    @Test  
    func stringManipulation() {  
        let text = "Hello"  
        #expect(text.count == 5)  
    }  
}

2. Structured Test Organization

Swift Testing introduces the @Suite macro, which enables you to group related tests together, making it easier to organize and manage your test suites.

@Suite  
struct UserTests {  
    let user = User(name: "John", age: 30)  
  
    @Test  
    func userProperties() {  
        #expect(user.name == "John")  
    }  
  
    @Test  
    func userValidation() {  
        #expect(user.isValid)  
    }  
}

Please note that the @Suite isn’t required for the testing library to recognize that a type contains test functions. It only allows more customization of a test suite’s appearance in the IDE and at the command line. Also the test suite can be nested by simply nesting another type that contains @Test functions.

3. Swift Concurrency Support

With the rise of Swift’s concurrency features, Swift Testing seamlessly integrates with async/await, making it easier to test asynchronous code.

struct NetworkTests {  
    @Test  
    func asyncRequest() async throws {  
        let service = NetworkService()  
        let result = try await service.fetchData()  
        #expect(result != nil)  
    }  
}  
  
  
@Test @MainActor func foodTruckExists() async throws { ... }  
  
@Test func priceLookupYieldsExpectedValue() async {  
  let mozarellaPrice = await unitPrice(for: .mozarella)  
  #expect(mozarellaPrice == 3)  
}

If we are not interested in the running result of the concurrency code and only interested in certain event happens or doesn’t happen instead, Swift testing offers confirmation(_:expectedCount:isolation:sourceLocation:_:) for these cases.

@Test  
func confirmSuccessEvent(endpoint: Endpoint) async {  
  let apiManager = APIManager()  
  await confirmation() { confirmation in  
    apiManager.successHandler = { \_ in confirmation() }  
    \_ = await apiManager.call(endpoint)  
  }  
}  
  
@Test func confirmNoErrorEvent(endpoint: Endpoint) async {  
  let apiManager = APIManager()  
  await confirmation(expectedCount: 0) { confirmation in  
    apiManager.errorHandler = { \_ in confirmation() }  
    \_ = await apiManager.call(endpoint)  
  }  
}

4. Test parameterization

Very often, the test cases need to cover many data samples. Instead of loop over the sample data in the code, Swift testing offers “argument” to the test cases to test over different inputs like below:

@Test(arguments: 1...10) // arguments configuration  
func numberTest(number1: Int) {  
    #expect(number1 > 0)  
}

Further more, the arguments will feed the full combination of different arguments into test cases.

@Test(arguments: 1...10, 20...30)  
func numberTest(number1: Int, number2: Int) {  
    #expect(number1 < number2)  
}

If that is not the expected behaviour, then use zip function like below:

@Test(arguments: zip(1...10, 21...30))  
func numberTest(number1: Int, number2: Int) {  
    #expect(number1 < number2)  
}

As you can see, the test case can run multiple times depending on the arguments configuration. Do we need to run all of the cases if we need to debug some failed cases? The answer is NO. We can simply conform the arguments to any of the protocol CustomTestArgumentEncodable , RawRepresentable (where RawValue conforms to Encodable), Encodable and Identifiable(where ID conforms to Encodable).

Swift Testing Traits

Swift Testing allows us to attach additional metadata using traits to the tests, such as tags, bugs, and arguments, providing more context and enabling better organization and reporting. There are also Swift Testing Traits that help to configure the test cases.

1. Parellelisation trait

By default, all the test cases generated by argument combination within a suite will run in parallel, leveraging the power of modern hardware and improving your overall testing efficiency. However, we can use parellelisation trait to modify this behaviour like below:

@Suite(.serialized) // serialized trait  
struct TestExample {  
  @Test(.serialized, arguments: 1...100) // serialized trait  
  func numberTest(number: Int) {  
    // This function will be invoked serially, once per number  
  }  
}

2. Comment trait

Comment is also a trait in Swift Testing and it’s just the comments that placed before @Test or @Suite macro. It can be used to provide context or background information about a test’s purpose, explain how a complex test operates, or include details which may be helpful when diagnosing issues recorded by a test.

Please note that developer can provide comments programmatically via trait protocol too, which will be covered later in this article.

3. Bug trait

Bug trait represents a bug report tracked by a test. We can use this trait to provide bug information like id, title and url like below:

@Test(  
    .bug(id: "12345"),  
    .bug("https://www.example.com/issues/67890", id: 67890)  
)  
func sometest() { }

4. Tag trait

Tag trait can be used to provide semantic information for organization, filtering, and customizing appearances. For example, we can use loggedIn, annoymous tags below to mark the tests.

extension Tag {  
  @Tag static var loggedIn: Self  
  @Tag static var annoymous: Self  
}  
  
@Test(.tags(.loggedIn))  
func userProfile() { ... }  
  
@Test(.tags(.annoymous))  
func unknownUser() { ... }

Then we can run all the tests under certain tags like below:

5. Condition Trait

Condition Trait allow us to conditionally enable or disable individual tests before they run.

@Test(.enabled()) // Could also add condition inside .enabled()  
func testEnabled() { }  
  
@Test(.disabled()) // Could also add condition inside .disabled()  
func testDisabled() { } 

6. Time Limit Trait

In order to avoid long run test cases, we can use this trait to set limits on how long a test can run for until it fails.

@Test(.timeLimit(.minutes(60)) // Maximum 60m to run this case, or it will fail  
func longRunTestCase() async {  
    let meal = await Chef.cook()  
    await meal.eat()  
}

7. Define our own traits

Apart from the above system defined traits, we can also define our own traits. There are three related protocols: Trait , SuiteTrait , TestTrait . For example, we can define a Feature TestTrait and use it like below:

enum Feature: TestTrait {  
    case a  
    case b  
  
    func prepare(for test: Testing.Test) async throws {  
        switch self {  
        case .a: // TODO: Test preparation for feature a  
        case .b: // TODO: Test preparation for feature b  
        }  
        print(test.comments)  
    }  
  
    var comments: \[Testing.Comment\] {  
        \["testing \\(self)"\]  
    }  
}  
  
struct SampleTests {  
    @Test(Feature.a)  
    func featureATest() {  
        // the Test preparation code will run before this test  
    }  
}

func prepare(for test: Testing.Test) async throwsof the TestTrait gives us an opportunity to prepare the test environment before running the test case, which is obviously beneficial if we have more test cases that marked with Feature trait.

var comments: [Testing.Comment] is needed if we want to programmatically add comments to test case.

Integrating Swift Testing with Existing Tests

One of the key benefits of Swift Testing is its ability to coexist and integrate with the existing XCTest framework. This allows you to gradually migrate your test suite to the new framework, taking advantage of its features while maintaining compatibility with your legacy tests.

// Traditional XCTest  
class LegacyTests: XCTestCase {  
    func testOldFeature() {  
        XCTAssertTrue(true)  
    }  
}  
  
// New Swift Testing  
struct ModernTests {  
    @Test  
    func newFeature() {  
        #expect(true)  
    }  
      
    // @available attribute can also be used  
    @available(macOS 11.0, \*)  
    @available(swift, introduced: 8.0, message: "Requires Swift 8.0 features to run")  
    @Test func foodTruckExists() { ... }  
}

Potential better/simplified method naming

If you follow swift evolution, you might have noticed that with this proposal, we could potential use the follow syntax to further simplify the syntax.

@Test func \`square returns x \* x\`() {  
    #expect(square(4) == 4 \* 4)  
}

Conclusion

Swift Testing represents a significant advancement in the iOS testing landscape, providing developers with a more expressive, organized, and concurrency-friendly approach to writing and managing their test suites. By leveraging the power of Swift’s syntax and language features, Swift Testing simplifies the testing process, improves readability, and enables better test organization and reporting.

As we continue to refine our iOS development practices, consider adopting Swift Testing to streamline our testing workflow and unlock new possibilities in the application’s quality assurance. With its seamless integration with existing XCTest-based tests, we can gradually migrate your test suite and start enjoying the benefits of this modern testing framework.

Comments

Sharing is caring!