Work

Products

Services

About Us

Careers

Blog

Resources

Getting Started with Swift Testing - A Clear and Expressive Approach to Testing in Swift
Image

Ananth Desai

Jul 30, 2025

Overview

This post explores Swift Testing, Apple’s new testing framework introduced in WWDC 2024 that modernizes iOS testing with Swift-first design principles and significantly improved developer ergonomics. The framework replaces XCTest’s fragmented assertion methods (XCTAssertEqual, XCTAssertTrue, etc.) with unified #expect and #require macros, eliminates rigid naming conventions in favor of flexible @Test and @Suite attributes, and introduces powerful organizational features. Beyond syntax improvements, Swift Testing delivers advanced capabilities including native parameterized testing with automatic parallel execution, seamless async/await integration, and sophisticated test management features.

  • Introduction
  • Setting Up the Testing Environment
  • Writing Your First Test
  • Test Suites
  • Assertions Kept Simple: The #expect Macro
  • Getting Rid of Optionals: The #require Macro
  • Traits
  • Enable/Disable Test Cases
  • Bug
  • Time Limit
  • Tags
  • Parameterized Tests
  • Concurrency
  • Current Limitations of Swift Testing

Introduction

After years of XCTAssertEqual, XCTAssertNotNil, and functions that must start with “test”, Apple has delivered something that feels like it was designed for Swift developers.

Introducing Swift Testing — a ground-up reimagining of how we write tests in Swift. Introduced in the WWDC24, it’s a complete paradigm shift that embraces Swift’s modern language features while solving many of the pain points that have plagued iOS testing for years.

Swift Testing introduces macros like #expect and #require that make your test intentions crystal clear. Want to run the same test with multiple parameters? No more copy-pasting test methods — parameterized tests handle this automatically, running in parallel for better performance. Need to organize tests across different features? Tags and traits provide flexible organization that goes far beyond simple grouping.

Whether you’re a seasoned iOS developer looking to modernize your approach towards testing in Swift or someone just getting started with Swift development, this guide will walk you through everything you need to know to get started with Swift Testing.

Setting Up the Testing Environment

Starting with Xcode 16, creating a new project in Xcode will automatically create a Swift Testing target for you.

Configuring a new project in Xcode

To add a new test target to an existing project, go to File > New > Target and select the Unit Testing Bundle template.

Adding a new test target to an existing project

Writing Your First Test

We start off with importing the Testing library. You can use your internal types and methods in the test file by importing them using @testable import.

Writing a test case using Swift Testing is pretty straightforward. Tests are defined using functions, and each function represents a single test case. To declare a test function, it is annotated with the @Test attribute.

import Testing
@testable import SwiftTesting

@Test func testAddition() {
    // test addition here
}

Writing your first test

Note that although the function name starts with test, this is not a requirement in Swift Testing unlike XCTest. You can name the function anything you want. As long as the function is attributed with @Test, it will be considered a test case.

You can also customise your test’s name by passing a string to the @Test attribute. There are multiple other instance properties that you can define in the @Test attribute. We’ll look at those later in the post.

import Testing
@testable import SwiftTesting

@Test("Addition Test") func testAddition() {
    // test addition here
}

Customising your test’s name

This test is present at the global scope. Tests can be grouped together in a specific type, and the type containing the tests is considered to be a test suite.

Test Suites

You can organize your test cases into test suites by placing the functions in a Swift type. The type can be a struct, class, or enum.

Test suites can be optionally annotated using the @Suite attribute.

@Suite struct SwiftTestingTests {
    @Test func testAddition() {
        // test addition here
    }
}

Creating a test suite

Although the library will recognise that a type is has tests even without the @Suite attribute, adding the @Suite attribute will allow you to customise the test suite’s name and other properties (Traits).

@Suite("Addition Tests") struct SwiftTestingTests {
    @Test func testAddition() {
        // test addition here
    }
}

This is the name that will be shown in your UI as well.

Customising your test suite’s name

Assertions Kept Simple: The #expect Macro

Swift Testing introduces a new #expect macro that allows you to write assertions in a more readable and expressive way. This macro can be used to assert booleans, nil checks, quantitative comparisons, and more. In essence, this macro is a replacement for the XCTAssert family of functions (XCTAssertEqual, XCTAssertTrue, XCTAssertNil, XCTAssertNotNil, XCTAssertGreaterThan, XCTAssertLessThanOrEqual, etc.).

import Testing

@Suite struct SwiftTestingTests {
    @Test func testAddition() {
        #expect(1 + 1 == 2)
    }
}

The below code showcases the different use cases of the #expect macro.

import Testing
@testable import SwiftTesting

@Suite("Simple Calculation Tests") struct SwiftTestingTests {
    private let calculator = Calculator()
    
    @Test func testAddition() {
        #expect (calculator.addition (1, 2) == 3)
    }
    
    @Test func subtractionTest() {
        #expect(calculator.subtraction (5, 1) == 4)
        #expect(calculator.subtraction (1, 5) == -4)
    }
    
    @Test func productOfNegativeNumbers() {
        #expect(calculator.multiplication(-2, -3) >= 0)
    }
    
    @Test func divisionByZero() {
        #expect(calculator.division (10, 0) == nil)
    }
}

Failure of a test case also shows a diff of the values being compared. This makes debugging a breeze when dealing with nested objects.

Showing a diff of the values being compared

Getting Rid of Optionals: The #require Macro

Swift Testing also introduces a new #require macro that allows you to assert that an optional is not nil before proceeding with the test. This macro can be thought of as a combination of the XCTAssertNotNil and XCTUnwrap functions.

Let’s say our division method is defined in a way where it returns an optional Int.

public func division(_ a: Int, _ b: Int) -> Int? {
    if (b == 0) {
        return nil
    }
    return a / b
}

Previously, we would have tested this method purely using the #expect macro:

@Suite("Simple Calculation Tests") struct SwiftTestingTests {
    private let calculator = Calculator()
    
    @Test func someCalculation() {
        let a = 10
        let b = 5
        let value = calculator.division(a, b)
        #expect(value != nil)
        #expect(calculator.multiplication(value!, b) == a)
    }
}

Although this works, it is not very readable. We are also forced to deal with optionals throughout the test case.

We can use the #require macro to solve both the problems. Upon failure, we can now throw an error with a custom message.

@Suite("Simple Calculation Tests") struct SwiftTestingTests {
    private let calculator = Calculator()
    
    @Test func divisionByZero() throws {
        let a = 10
        let b = 0
        let value = try #require(calculator.division(a, b), "Division by zero is not allowed")
        #expect(calculator.multiplication(value, b) == a)
    }
}

Using the #require macro

Traits

Let us now look at the most interesting part of Swift Testing: Traits.

Traits can be thought of as properties that can be passed to annotations to describe, categorise, and modify their runtime behaviour. You can pass traits to both test functions (TestTraits) as well as test suites (SuiteTraits).

The descriptions for the test functions (@Test("Addition Test")) and test suites(@Suite("Simple Calculation Tests")) that we provided earlier are some of the examples of description traits.

There are many built-in traits that cover most common use cases, but you can also define your own traits. We’ll only look at the built-in traits in this post. To learn more about defining your own traits, refer to the official documentation.

Enable/Disable Test Cases

You can use the .enabled() and .disabled() traits to enable or disable a test case based on conditions. Take the following example:

@Suite struct SwiftTestingTests {
    @Test(.disabled("The `divide` method has a bug that is yet to be fixed. Skipping the test."))
    func calculatePrincipalInterest() {
        // calculate principal interest here
    }

    @Test(.enabled(if: InterestCalculator.hasInterestRates))
    func calculateFDInterest() {
        // calculate interest here
    }
}

Here, we’ve disabled the first test case because of a bug in the divide method. The second test case is only enabled if the InterestCalculator has principal rates. You can also pass multiple conditions in the .enabled() and .disabled() traits, and all of them will be checked in a logical AND fashion.

@Test(.enabled(if: InterestCalculator.hasPrincipalAmount,
               if: InterestCalculator.hasInterestRates))
func calculateInterest() {
    // calculate interest here
}

In the previous example, the calculatePrincipalInterest was disabled due to a bug. Swift Testing also provides another way to handle known issues: using withKnownIssue().

@Suite struct SwiftTestingTests {
    @Test
    func calculatePrincipalInterest() {
        withKnownIssue("The `divide` method has a bug that is yet to be fixed.") {
            #expect(InterestCalculator.calculatePrincipalInterest(100, 10) == finalAmount)
        }
    }
}

This way, the test does not have to be disabled but the library will not fail the test if the assertions inside the withKnownIssue block fails because it is a known issue.

Bug

You can annotate a test case or suite with a .bug() trait to keep track of the bugs in your codebase. This trait accepts a url that links to the bug report if available. Note that this does not skip the test case/suite.

@Suite struct SwiftTestingTests {
    @Test(.bug("https://my-project.atlassian.net/browse/PD-57"))
    func calculatePrincipalInterest() {
        // calculate principal interest here
    }
}

Time Limit

Adding the .timeLimit() trait to a test case/suite will fail the test if it exceeds the specified time limit. This trait accepts a Duration as a parameter to define the time limit in minutes.

@Suite(.timeLimit(.minutes(5))) struct SwiftTestingTests {
    @Test(.timeLimit(.minutes(1)))
    func calculatePrincipalInterest() {
        // calculate principal interest here
    }

    // ... other test cases
}

Tags

Although Suites provide a way to group tests, it is not the only way to organise tests. Sometimes, we might need to merge multiple tests or suites together that use the same core functionality, but are not directly related to each other. This is where Tags come in.

Consider a scenario where you have three different features to which you’re required to add tests: LoanCalculator, FixedDepositCalculator and SavingsCalculator. Each of these features has its own set of functionalities but they all provide one common functionality: what is the final interest amount?

We’ll define two tags to represent simple calculation tests and complex interest calculations: simpleTest and interestValue.

import Testing

extension Tag {
    @Tag static var simpleTests: Self
    @Tag static var interestValue: Self
}

And we use these tags to annotate our test cases accordingly:

@Suite(.tags(.simpleTests)) struct SimpleTests {
    private let calculator = Calculator()

    @Test
    func testAddition() throws {
        #expect(calculator.addition(10, 20) == 30)
    }
    
    @Test
    func subtractionTests() throws {
        #expect(calculator.subtraction(10, 20) == -10)
    }
    
    @Test
    func productTests() throws {
        #expect(calculator.multiplication(10, 20) == 200)
    }
    
    @Test
    func testDivider() throws {
        let value = try #require(calculator.division(20, 10), "Division by zero is not allowed")
        #expect(value == 2)
    }
}
@Suite struct LoadCalculatorTests {
    private let loanCalculator = LoanCalculator(interestRate: 10, principal: 100_000, years: 2)
    
    @Test(.tags(.simpleTests)) func validateInitialValues() {
        #expect(loanCalculator.interestRate == 10)
        #expect(loanCalculator.principal == 100_000)
        #expect(loanCalculator.years == 2)
    }
    
    @Test(.tags(.interestValue)) func calculateTotalInterest() {
        // calculate interest here
    }
}
@Suite struct FixedDepositCalculatorTests {
    private let fixedDepositCalculator = FixedDepositCalculator(interestRate: 7, principal: 200_000, years: 5)
    
    @Test(.tags(.simpleTests)) func validateInitialValues() {
        #expect(fixedDepositCalculator.interestRate == 7)
        #expect(fixedDepositCalculator.principal == 200_000)
        #expect(fixedDepositCalculator.years == 5)
    }
    
    @Test(.tags(.interestValue)) func calculateTotalInterest() {
        // calculate interest here
    }
}
@Suite struct SavingsCalculatorTests {
    private let savingsCalculator = SavingsCalculator(interestRate: 5, principal: 1_000_000, years: 10)
    
    @Test(.tags(.simpleTests)) func validateInitialValues() {
        #expect(savingsCalculator.interestRate == 5)
        #expect(savingsCalculator.principal == 1_000_000)
        #expect(savingsCalculator.years == 10)
    }
    
    @Test(.tags(.interestValue)) func calculateTotalInterest() {
        // calculate interest here
    }
}

The Xcode UI will now group all the tests together based on the tags that you’ve provided:

Grouping tests based on tags

You can choose to run the tests based on specific tags. This will be helpful to run tests that are related to a specific feature or functionality that was changed.

Running tests based on tags

Parameterized Tests

Swift Testing also provides a way to write parameterized tests. Parameterized tests are tests that are run multiple times with different input values.

@Suite struct ParameterizedTests {
    @Test(arguments: [1, 2, 3, 4, 5])
    func square(_ value: Int) throws {
        #expect(calculator.square(value) == value * value)
    }
}

This will run the square test case 5 times, once for each value in the arguments array. The arguments array can contain any type of values, but all the values must be of the same type. The test case should accept the parameters that represents the values from the arguments array.

Running parameterized tests

Parameterized tests are useful when you want to test the same functionality with different input values. In the above example, we defined different test cases for different interest calculators. With parameterized tests, we can define a single test case that can test all the interest calculators.

enum InterestType {
    case loan
    case savings
    case fixedDeposit
}
@Suite struct InterestCalculatorTests {
    private let loanCalculator = LoanCalculator(interestRate: 10, principal: 100_000, years: 2)
    private let fixedDepositCalculator = FixedDepositCalculator(interestRate: 7, principal: 200_000, years: 5)
    private let savingsCalculator = SavingsCalculator(interestRate: 5, principal: 1_000_000, years: 10)
    
    @Test("Calculate interest for different transactions", arguments: [InterestType.loan, .savings, .fixedDeposit])
    func calculateInterestForDifferentTransactions(_ interestType: InterestType) throws {
        switch interestType {
        case .loan:
            // calculate loan interest amount
            break
        case .savings:
            // calculate savings
            break
        case .fixedDeposit:
            // calculate fixed deposit interest amount
            break
        }
    }
}

The arguments are independent of each other, so the test case will be run for each argument in parallel.

Note: Swift testing will run every combination of arguments in parallel. So, if you have 2 arguments arrays, each with 5 values, the test case will be run 25 times.

@Test(arguments: [1, 2, 3, 4, 5], [1, 2, 3, 4, 5])
func parameterizedAddition(_ value1: Int, _ value2: Int) throws {
    #expect(calculator.addition(value1, value2) == value1 + value2)
}

Running parameterized tests with multiple arguments

This is not a bug. This is a feature.

This allows you to test all the combinations of the arguments and create a robust test suite with minimal code. If you want to test each argument independently, you can use the zip() function to run the test with only the combinations that you’ve defined.

@Test(arguments: zip([1, 2, 3, 4, 5], [1, 2, 3, 4, 5]))
func parameterizedAddition(_ value1: Int, _ value2: Int) throws {
    #expect(calculator.addition(value1, value2) == value1 + value2)
}

Running parameterized tests with multiple arguments using zip

Concurrency

You can still use async and await to write asynchronous tests.

@Suite struct AsyncTests {
    @Test
    func testAsyncFunction() async throws {
        let value = try await someAsyncFunction()
        #expect(value == 10)
    }
}

Current Limitations of Swift Testing

  • Performance testing is an important part of any testing suite. Unfortunately, Swift Testing does not yet provide a way to write performance tests. You’ll still have to use XCTest for that.
  • Swift Testing does not yet provide a way to write UI tests.
  • Tests which can only be written in Objective-C must be written using XCTest. However, you can write Swift tests for code written in other languages using Swift Testing.

Although these drawbacks are not a deal-breaker, they are still areas that need to be addressed in the future. Swift Testing is open source, and is still in its early stages and is being actively worked on. Hopefully, these features will be added soon.

We Build Digital Products That Move Your Business Forward

locale flag

en

Office Locations

India

India

502/A, 1st Main road, Jayanagar 8th Block, Bengaluru - 560070

France

France

66 Rue du Président Edouard Herriot, 69002 Lyon

United States

United States

151, Railroad Avenue, Suite 1F, Greenwich, CT 06830

© 2025 Surya Digitech Private Limited. All Rights Reserved.