Exploring Swift Testing: A Guide to the Latest iOS Testing Framework

Feyyaz ONUR
6 min readSep 24, 2024

--

https://designer.microsoft.com/image-creator

Introduction

As a junior iOS developer, diving deep into testing frameworks can feel overwhelming, especially with new updates and best practices. During WWDC24, Apple introduced an exciting new framework called Swift Testing, designed to simplify and enhance the testing experience compared to the older XCTest framework

In this post, I’ll share what I’ve learned about Swift Testing and how it can help you write better tests more efficiently.

This is a learning process for me, feel free to provide feedback or suggestions to improve the blog further! Here is Feyyaz’s blog, welcome!

Introduction to Swift Testing

Apple’s Swift Testing introduces powerful features to streamline the testing process. If you’ve been using XCTest, you’ll notice some big improvements, such as easier ways to handle test cases, parallel execution, and more intuitive syntax. This article will highlight the main components of Swift Testing, comparing it to XCTest, and share practical insights on how to use it effectively.

Building Blocks of Swift Testing

Swift Testing is built on four main components:

  1. Test functions
  2. Expectations
  3. Traits
  4. Suites

Let’s explore each one.

Test Functions

With Swift Testing, you don’t need to subclass XCTestCase anymore. Instead, define a test case using the @Test property wrapper. This greatly enhances simplicity and readability.

Plus, you can give your tests more descriptive names:

@Test("Check video metadata") func videoMetadata() {
// Test implementation
}

No more cryptic function names like testVideoMetadata. Now, you can use human-readable names, which makes it clearer.

Expectations

Swift Testing introduces the #expect() macro to check conditions. This works similarly to assertions in XCTest:

#expect(1 == 2)
#expect(user.name == "Alice")
#expect(!array.isEmpty)
#expect(number.contains(4))
Meet Swift Testing (https://developer.apple.com/videos/play/wwdc2024/10179)

If you want to stop the test when a condition fails, use try #require() — great for unwrapping optionals or enforcing critical conditions:

try #require(user.name != nil)
Meet Swift Testing (https://developer.apple.com/videos/play/wwdc2024/10179)

Traits

Traits allow you to modify the behavior of your tests, such as:

  • Skipping certain tests
  • Marking expected failures
  • Setting timeouts

This adds flexibility to your testing process without needing complex logic in your tests.

Suites

Suites help you organize related tests. You can group @Test functions within a struct for better structure and management:

@Suite("Food Ordering Tests")
struct FoodOrderingTests {
@Test("Add item to cart") func addItemToCart() {
let cartService = CartService()
let foodItem = FoodItem(id: 1, name: "Pizza", price: 10.99)

cartService.addItem(foodItem)
let cartItems = cartService.getItems()

#expect(cartItems.count == 1)
#expect(cartItems.first?.name == "Pizza")
}

@Test("Remove item from cart") func removeItemFromCart() {
let cartService = CartService()
let foodItem = FoodItem(id: 2, name: "Burger", price: 8.99)

cartService.addItem(foodItem)
cartService.removeItem(foodItem)
let cartItems = cartService.getItems()

#expect(cartItems.isEmpty)
}
}

Suites also support traits, allowing you to apply specific behaviors to all tests within a suite.

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

Common Workflows

Swift Testing supports several common testing workflows:

  1. Tests with conditions
  2. Tests with common characteristics
  3. Tests with different arguments (parameterized testing)

Tests with conditions

Sometimes, tests need to be executed only when certain conditions are met, such as when a specific feature is enabled or a certain device is being used. Swift Testing provides easy ways to manage these conditions.

Conditional Test Execution

You can use the .enabled(if: ...) trait to define runtime-evaluated conditions for your tests. This trait ensures that a test is only run if the specified condition is true. If the condition is false, the test will be skipped automatically.

@Test(.enabled(if: AppFeatures.isCommentingEnabled)) 
func videoCommenting() {
// Test implementation
}

Disabling Tests

There are also cases where you may need to disable tests. You can also disable tests with the .disabled(...) trait. You can provide a reason for disabling the test, which can be displayed in your CI system for transparency and visibility.

This is especially useful when the test is being disabled due to a known bug or issue that is being tracked in a bug-tracking system.

@Test(.disabled("Due to a known crash"),
.bug("example.org/bugs/1234", "Program crashes at <symbol>"))
func example() {
// Test implementation
}

The example() test is disabled because of a known crash, and the .bug(...) trait is used to reference the related issue with a URL, making it easy for developers and teams to track the problem.

OS-Specific Test Execution

Instead of checking availability at runtime with #available(...), you can use the @available(...) attribute to define OS-specific conditions for tests. This helps ensure that the test is only executed on systems that support the required APIs.

@Test
@available(macOS 15, *)
func usesNewAPIs() {
// Test implementation
}

In this case, the usesNewAPIs() test will only run on macOS 15 or later, preventing potential crashes or issues on older systems that don’t support the new APIs.

Tests with Common Characteristics

You can tag your tests and then filter or run tests by these tags. For example, if several tests are related to formatting:

extension Tag {
@Tag static var formatting: Self
}

@Test(.tags(.formatting)) func testFormatting() {
// Test implementation
}

You can also give a tag to a suite (struct-group) of tests. This allows you to:

  • Run all tests with a specific tag
  • Filter by tag
  • See insights in the Test Report

Parameterized Testing

Run the same test function with different inputs, reducing code duplication:

@Test("Validate user age", arguments: [18, 21, 65, 34, 77, 24, 13])
func testUserAge(_ age: Int) {
let user = User(age: age)
#expect(user.isAdult)
}

Each input runs as an independent test, and since tests are run in parallel, this approach saves time and keeps your test suite efficient.

When you use a for loop (instead of using parameterized) for all cases with try #require(..), it might not test all the cases if one fails.

Not only with enums, parameter input types can include any sendable collection, including arrays, dictionaries, ranges, sets, and more.

You can use .zip to avoid too many combinations in parameterized tests.

Testing in Parallel

Swift Testing runs test functions in parallel by default, with no need for extra code:

  • Execution time is saved.
  • The order in which tests run is randomized.
  • This is not the default in XCTest.

If you want to run tests serially (one after the other), you can use the .serialized trait.

  • This can be used in parameterized test functions to ensure test cases run one at a time.
  • Sub-suites automatically inherit this trait, so you don’t need to add it twice.

Whenever possible, aim to run tests in parallel for better performance.

When to Use XCTest

You’ll still need XCTest for:

  • UI automation APIs (XCUIApplication)
  • Performance testing APIs (XCTMetric)
  • Testing Objective-C code (Swift Testing is not compatible)

Challenges in Testing

Be aware of common testing challenges, including:

  • Readability
  • Code coverage
  • Organization
  • Fragility

Advanced Topics

Throwing and Catching Errors

You can test for specific errors and make sure your code throws the right error when necessary. It’s important to show users the correct throws.

Instead of using the traditional do-catch blocks to validate that the correct errors are thrown, you can use the #expect(throws: ...) macro.

Go further with Swift Testing (https://developer.apple.com/videos/play/wwdc2024/10195)

If the function throws an error, then this test passes. If no error was thrown, the test fails.

Expected Failures

For expected failures, you can use withKnownIssue { /* ... */ }.

struct FoodTruckTests {
@Test func grillWorks() async {
withKnownIssue("Grill is out of fuel") {
try FoodTruck.shared.grill.start()
}
...
}
...
}

Conclusion

The new Swift Testing framework offers simplicity, parallel execution, and features like parameterized tests.

Stay tuned — I’ll be sharing a new SPM package for the network layer soon, along with Swift Testing examples!

References

This story is also available on LinkedIn. You can check out my LinkedIn profile here.

--

--