
Ananth Desai
Jul 30, 2025
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.
To add a new test target to an existing project, go to File > New > Target
and select the Unit Testing Bundle
template.
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
}
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
}
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
}
}
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.
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.
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)
}
}
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:
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.
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.
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)
}
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)
}
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.