Swift Testing: A Modern Approach to Code Reliability

Avi Tsadok
8 min readJun 12, 2024

--

A Comprehensive Guide to Swift Testing

As professional developers, we are driven by code efficiency, architecture, and project planning. However, testing is often important. It is the backbone of our work, ensuring the reliability and functionality of our code.

Even though the term Swift Testing sounds quite generic, testing in iOS is not new. Let’s go over its history in a nutshell.

Requirements

It’s important to state that this article requires basic knowledge about Swift and experience in iOS testing. I won’t explain why we need testing or how to run tests. If you want to delve into testing, you can buy my book, “Pro iOS Testing,” by Apress.

Going over the history of iOS Testing

IOS has always existed, but Xcode, the platform we rely on, is even older. Apple announced Xcode in 2003; at that time, it had no built-in testing tools. The evolution of testing tools in Xcode is a testament to the continuous advancements in our field, and we should all appreciate it.

In 2005, Apple integrated OCUnit, a third-party testing framework for Objective-C, into Xcode.

In 2013, during WWDC, Apple announced XCTest, a modern framework that supports unit, performance, and UI tests for iOS and macOS applications.

So why did Apple announce Swift Testing?

As part of the move to Swift, it is time to have a more natural and lightweight testing framework that can scale and handle big projects.

Let’s start setting up a project with Swift Testing and write our first test.

Writing our first test with Swift Testing

XCTest still exists along with Swift Testing. However, Swift Testing is now the default testing framework.

When we set up a new project, we get to choose the testing framework we are going to use:

Creating a new Project

We can see that Swift Test is now under the Testing System popup menu. Notice that UI Tests are still being done with the XCTest framework.

The same goes for creating a new Swift Package:

Creating a new Swift Package

Notice that we now have a new options screen when creating a new Swift Package, containing only one option (the Testing System). In this article, we will work on a great Swift package called UnitConverted, which amazingly converts one unit to another (this task is so heavy it requires iPhone 15 Pro!).

The UnitConverter Switt Package

We can see that we have one test product — the UnitConverterTests.

Let’s see our UnitConverted structure:

struct UnitConverter {    
func celsiusToFahrenheit(celsius: Double) -> Double {
let fahrenheit = (celsius * 9/5) + 32
return fahrenheit
}
}

Our UnitConverted contains one function that converts Celsius degrees to Fahrenheit.

Let’s test our celsiusToFahrenheit fucntion:

import Testing
@testable import UnitConverter

@Test
func convertCelsiusDegrees() {
#expect(UnitConverter().celsiusToFahrenheit(celsius: 10) == 50)
}

Even though it is a short code snippet, we can notice several stuff here:

  • No XCTestCase class — we don’t need any class to wrap the test functions.
  • Import Testing — the new Swift Testing framework is called Testing. We should import it into each file we want to have tests on.
  • There is no need to start the test function with the word Test — instead, we add the @Test annotation before the function declaration.
  • No asserts — now, we have a new macro called #expect, which receives a regular boolean expression.

Four lines of code and so many changes! Indeed, the Swift Testing framework is a significant change compared to the XCTest we are used to.

Let’s investigate the @Test macro.

Expanding the @Test macro

The @Test macro is the heart and soul of our testing function. It defines running conditions, behaviors, and semantic declarations.

For example, let’s give our test a name:

@Test("Convert Celsius to Fahrenheit")

Now, it appears more evident in the testing pane in Xcode instead of a blurry camelCase:

The test function name in Xcode

We can turn off the test using the disabled enum:

@Test("Convert Celsius to Fahrenheit", .disabled())

In this case, the test will be skipped and not run.

Or, we can enable it only when it meets a certain condition using enabled:

@Test("Convert Celsius to Fahrenheit", .enabled(on: AppSettings.FeatureEnabled))

This is an excellent capability for feature flags or a/b test testing.

However, tags are among the most excellent features of the @Test macro.

Working with tags

The best practice is grouping tests according to classes or screens. And it is! In most cases, that’s the right way to go, as we usually manage tests by concern. However, there’s an additional dimension where we can group tests, such as semantic definition. For example, maybe we want to mark all the test functions that test calculations or user behavior. This is why tags exist.

To work with Tags and tests, first, let’s create a new tag we can use:

extension Tag {
@Tag static let calculations: Self
}

In this code, we extended the Tag structure and created a new static constant called calculations.

Now, we can use the calculations constant to make our test function:

@Test("Convert Celsius to Fahrenheit", .tags(.calculations))

We can also mark a test function with multiple tags:

@Test("Convert Celsius to Fahrenheit", .tags(.calculations, .critical))

In this example, our test function now has two tags — calculations and critical. We can see the grouping for tags in the tags tab under the testing pane in Xcode:

The tags tab

We can see that our test function appears in both tags and critical.

What’s great about tags is that we can now perform actions by tags. For example, we can run only the critical test (by tapping on the triangle on the critical tests row):

Running only critical tests

Or filter tests by tags in the test plan configuration screen:

Filtering tests by tag in the test plan screen

To summarise — tags are a great addition to testing!

Now, let’s go back to our test function. Our test function tests one use case when the Celsius value is 10. What about other cases? Well, the naive way is to duplicate our function and change the values:

@Test("Convert 10 Celsius to Fahrenheit", .tags(.calculations))
func convertDegrees_10() {
#expect(UnitConverter().celsiusToFahrenheit(celsius: 10) == 50)
}

@Test("Convert 15 Celsius to Fahrenheit", .tags(.calculations))
func convertDegrees_15() {
#expect(UnitConverter().celsiusToFahrenheit(celsius: 15) == 59)
}

This solution works well but doesn’t scale up well. So, let’s review another great feature in Swift Testing: parameterized testing.

Performing parameterized testing

parameterized testing is performing the same test repeatedly with different values and expectations. This allows us to test our function with various use cases. Yet, the XCTest framework doesn’t support parameterized testing, which requires us to rent tricks to avoid duplicating our code.

Luckily, parameterized testing is a built-in solution in Swift Testing.

The first thing we need to do is to add the attributes parameter to the @Test macro:

@Test("Convert Celsius to Fahrenheit", .tags(.calculations),
arguments: [10, 15, 0])

The arguments parameter ensures that we run the test several times, each with a different value.

To “inject” the value into the test function, we need to add a function parameter to our test and use it in our code:

@Test("Convert Celsius to Fahrenheit", .tags(.calculations),
arguments: [10, 15, 0])
func convertDegrees(celsius: Double) {
let expectations : [Double: Double] = [10:50, 15:59, 0:32]
#expect(UnitConverter().celsiusToFahrenheit(celsius: celsius) == expectations[celsius] ?? 0)
}

Now, our #expect macro calls the celsiusToFahrenheit function with the celsius parameter we added. Our test function runs three times, each with a different value from 10 to 15 and 0.

For convenience, I added a dictionary of different expectations for different inputs:

let expectations : [Double: Double] = [10:50, 15:59, 0:32]

Obviously, you can use any solution to perform the check if needed.

Now, let’s see what happens when we run the test function:

Running the test function with different values

We can see a different test run for each attribute.

Parameterized testing is a powerful and welcome addition to iOS testing and can cover our code even better.

Now, we know how to ts in Swift Testing, including parameterized tests, and how to group our tests semantically using tags. We said that Swift Testing is a modern framework aligned with the Swift language. So, let’s see how we can structure our tests using the Swift language only.

Structure our tests using test suites

In the XCTest framework, we had to create an XCTestCase class to group several tests together. In Swift Testing, we don’t need to use the XCTestCase; we can use simple structs sts.

For example, let’s create a struct to group our conversation tests:

struct ConversationTests {

@Test("Convert Celsius to Fahrenheit", .tags(.calculations),
arguments: [10, 15, 0])
func convertDegrees(celsius: Double) {
let expectations : [Double: Double] = [10:50, 15:59, 0:32]
#expect(UnitConverter().celsiusToFahrenheit(celsius: celsius) == expectations[celsius] ?? 0)
}
}

In this code example, we create a struct named ConversationTest, which contains our convertDegrees test function.

Creating a structure for tests helps in several ways. First, it allows us to perform test setup and clean up using the init and deinit functions. The init and deinit function runs for each test separately.

It also helps us to have variables or helper functions that we can use in all test functions. For example, we can reuse the expectations dictionary we created earlier by moving it to the struct level:

struct ConversationTests {

let expectations : [Double: Double] = [10:50, 15:59, 0:32]

@Test("Convert Celsius to Fahrenheit", .tags(.calculations),
arguments: [10, 15, 0])
func convertDegrees(celsius: Double) {
#expect(UnitConverter().celsiusToFahrenheit(celsius: celsius) == expectations[celsius] ?? 0)
}
}

But, the real power of structs is when we use the @Suite macro, which is similar to what we did in the test function.

For example, we can provide a readable name for our suite, including tags:

@Suite("Conversation Tests", .tags(.calculations))
struct ConversationTests {
....
}

Now, let’s run our test suite and see how it looks in the testing pane:

Testing suite in the testing pane

The testing suite appears with a readable name, including all of its tests underneath.

Summary

Swift Testing is an important addition to iOS Testing. In this chapter, we went over setting up a basic test target, creating a test function using the @Test macro, customizing its behavior, creating tags and parameterized tests, and creating a test suite.

By now, you should be able to test your code quickly!

In the article, we will investigate this framework even deeper.

--

--

Avi Tsadok

Head of Mobile at Melio, Author of “Pro iOS Testing”, “Mastering Swift Package Manager” and “Unleash Core Data”