Swift and SwiftUI tutorials for Swift Developers

Swift Testing Setup

If you have been immersed in Swift programming for a while, it is very likely that your relationship with XCTest has had its ups and downs. Although it has been the default tool for years, the rapid evolution of the ecosystem toward more declarative paradigms—driven by Swift and SwiftUI—necessitated a renewal in how we verify our code.

Apple has responded with Swift Testing, a modern, open-source framework designed specifically to harness the current power of the Swift language.

In this in-depth technical tutorial, we will explore what Swift Testing is, how to set it up in Xcode, and how to transform your quality workflow in iOS, macOS, and watchOS applications. If you are an iOS developer looking to modernize your tech stack and write clearer, more powerful tests, this article is for you.


What is Swift Testing and Why Does It Represent the Future?

Swift Testing is the new generation of automated testing tools in the Apple ecosystem. Unlike XCTest, which carries legacy architecture from the Objective-C era, Swift Testing has been built upon modern Swift features, highlighting the intensive use of Macros and structured concurrency.

Key Differences vs. XCTest

For an iOS developer, the mindset shift focuses on expressiveness and safety:

  1. Macro-based Syntax: Say goodbye to the verbosity of XCTAssertEqual or XCTAssertTrue. We now use natural expressions with #expect and #require.
  2. Descriptive Tests: Tests can have human-readable names and detailed metadata without relying on function naming conventions (func testLongName()).
  3. Native Concurrency: It works harmoniously with async/await, eliminating the need for XCTestExpectation in the vast majority of scenarios.
  4. Parameterized Tests: Running the same test logic with multiple data sets is now a native and extremely simple functionality to implement.
  5. Cross-Platform: It is designed to be consistent across all platforms where Swift runs (including Linux and Windows), not just Apple devices.

Setup: Environment Configuration in Xcode

Integrating Swift Testing into your workflow is straightforward, as modern versions of Xcode support it natively. The great advantage is that this framework can peacefully coexist with XCTest in the same project, allowing for a frictionless, gradual migration.

Step 1: Configure the Test Target

For a new project:

  1. Go to File > New > Project.
  2. When configuring project options, ensure the Include Tests box is checked.
  3. Xcode will automatically configure the default testing system (which in recent versions already prioritizes Swift Testing).

For an existing project:

  1. Navigate to your project’s target editor.
  2. Add a new Unit Testing Bundle by clicking the + button.
  3. Verify that your project’s Deployment Target is compatible with modern Swift features (generally iOS 16+, macOS 13+, watchOS 9+ to leverage full macro capabilities, although the framework supports older versions via back-deployment).

Step 2: Import the Framework

In your test file, the import is clean and direct. Forget about import XCTest.

import Testing
@testable import MyAwesomeApp

struct MyFirstTests {
    // We will define tests here
}

Architecture Note: In Swift Testing, it is recommended to use structs or actors to define your test suites, rather than classes. By using struct, you guarantee that each test runs in its own isolated instance, avoiding dreaded “shared state” errors that often occur in poorly managed classes.


Anatomy of a Test in Swift Testing

Let’s dissect the syntax. Apple’s goal with this framework is for test code to be as readable as documentation.

The @Test Macro

To turn a function into an executable test, simply annotate it with @Test. The function name no longer needs mandatory prefixes.

@Test("Verify that VAT calculation is correct")
func vatCalculation() {
    let price = 100.0
    let vat = price * 0.21
    #expect(vat == 21.0)
}

The string you pass as an argument to the macro is what you will see in the Xcode “Test Navigator.” This is vital for documenting the intent of your code, an essential practice in high-level Swift programming.

Assertions: #expect vs #require

The framework simplifies dozens of old assertions into two powerful macros:

  1. #expect(condition): Evaluates a boolean expression. If the condition is false, the test is marked as failed, but code execution continues. This is the direct equivalent of traditional assertions.
#expect(user.name == "John")
#expect(items.count > 0)

The magic here is that if it fails, Xcode will expand the macro to show you exactly the variable values at the moment of failure, without needing to write custom error messages.

try #require(condition): Evaluates an expression. If it fails, the test stops immediately by throwing an error. This is crucial when you need to unwrap an optional (Optional Unwrapping) to proceed with the test.

let user = try #require(service.getUser(id: 1))
// If the previous line fails, the test aborts here.
// This prevents crashes on the next line:
#expect(user.isActive)

Parameterized Tests: Power with Less Code

For an iOS developer, one of the most tedious tasks is writing multiple tests to validate the same function with different inputs. Swift Testing solves this at the root.

Imagine you are developing a SwiftUI app and need to validate a password form.

struct PasswordValidator {
    static func isValid(_ pass: String) -> Bool {
        return pass.count >= 8
    }
}

// Parameterized Test
@Test("Password length validation", arguments: [
    ("short", false),
    ("12345678", true),
    ("veryLongSecurePassword", true),
    ("", false)
])
func validateLength(password: String, expectedResult: Bool) {
    let isValid = PasswordValidator.isValid(password)
    #expect(isValid == expectedResult)
}

With this single block of code, Xcode will generate and run 4 distinct tests. In the results report, you will see each case individually. If the empty string case fails, you will know exactly which one it was, keeping the rest of the validations intact.


Professional Organization: Traits and Tags

In complex projects, running the full test suite can take time. Swift Testing introduces a flexible and strongly-typed Tagssystem.

Creating Custom Tags

First, extend the Tag structure in your test code:

import Testing

extension Tag {
    @Tag static var critical: Self
    @Tag static var ui: Self
    @Tag static var integration: Self
}

Applying Tags to Tests or Suites

You can tag an individual function or group an entire structure (Suite) under a tag.

@Suite("Network Integration Tests")
@Tag(.integration)
struct APITests {
    
    @Test("Successful Login", .tags(.critical))
    func login() async throws {
        // Async test logic
    }
}

This allows you to filter execution in Xcode (or CI/CD) to run, for example, only .critical tests before a quick commit.


Swift Testing in a SwiftUI Environment (MVVM)

To ground these concepts, let’s look at a real-world example of how to test a ViewModel in a modern app built with Swift and SwiftUI.

The Code (ViewModel)

import SwiftUI

@MainActor
class TaskListViewModel: ObservableObject {
    @Published var tasks: [String] = []
    
    func add(task: String) {
        guard !task.isEmpty else { return }
        tasks.append(task)
    }
    
    func clearAll() {
        tasks.removeAll()
    }
}

The Test with Swift Testing

Here we will leverage concurrency. Since the ViewModel is marked with @MainActor (mandatory for UI updates in SwiftUI), our test must respect that isolation.

import Testing
@testable import MyTaskApp

@Suite("Task Management ViewModel")
struct TaskTests {
    
    @Test("Adding valid task increases counter")
    func addTask() async {
        // Arrange
        let viewModel = await TaskListViewModel()
        
        // Act
        await viewModel.add(task: "Learn Swift Testing")
        
        // Assert
        let count = await viewModel.tasks.count
        #expect(count == 1)
        
        let firstTask = await viewModel.tasks.first
        #expect(firstTask == "Learn Swift Testing")
    }
    
    @Test("Adding empty task does nothing")
    func addEmptyTask() async {
        let viewModel = await TaskListViewModel()
        
        await viewModel.add(task: "")
        
        let count = await viewModel.tasks.count
        #expect(count == 0)
    }
}

Notice the cleanliness of the code. Handling async is natural. There is no dealing with expectations, no wait, and no complex callbacks. The framework manages waiting for asynchronous functions automatically.


Migration and Coexistence: What About XCTest?

A common question is: “Do I need to rewrite all my old tests?” The resounding answer is no.

Swift Testing and XCTest are designed to coexist. Xcode detects both types of tests in your project and runs them in the same session.

  • Use Swift Testing for all new code and new features.
  • Keep your old XCTest tests until you need to refactor that part of the code.
  • Keep in mind that, for the moment, XCTest is still necessary for UI Testing (automated user interface tests) and performance tests (measure), areas where Swift Testing integrates or delegates.

Common Errors and Best Practices

When adopting this new tool in your Swift programming routine, consider these tips:

  1. Avoid Force Unwrap (!): Never use ! in your tests. If the value is nil, it will cause a crash that stops the entire test suite. Always use try #require(value).
  2. Preference for Structs: As mentioned, use struct to define your suites. If you use class, you run the risk of modifying a property in Test A that affects the result of Test B if you don’t clean the state correctly. Structs eliminate this problem by design.
  3. Atomic Tests: Each @Test should test only one thing. Thanks to parameterized tests, there is no longer an excuse to bundle many disparate assertions into a single function.

Conclusion

Swift Testing is not just a syntactic change; it is a necessary evolution in the maturity of development for Apple platforms. For the iOS developer, it represents a tool that is safer, faster, and aligned with the modern philosophy of the language.

By integrating this framework into your Swift and SwiftUI projects, you will discover that writing tests stops being a tedious task and becomes a fluid and valuable part of development. The ability to use smart macros, simplified concurrency, and the ease of parameterized tests will allow you to elevate the quality of your apps with less effort.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to write Unit Tests in Swift

Next Article

Swift Testing vs XCTest

Related Posts