Mastering Swift and SwiftUI

Insights, Tips, and Tutorials for iOS Developers

Getting Started with Swift Testing

Published on Sep 23, 2024

Get a quick glimpse of how Tiny Currency simplifies currency conversion with up to date rates, multi-currency support, and easy-to-use widgets. Perfect for on-the-go use, our app ensures you're always prepared, no matter where your travels take you.

Swift Testing is an open source Swift package from swiftlang that provides a testing framework built entirely in Swift and utilizing the power of Swift macros. The framework has been out for a while, but now it is built right into Xcode!

🧑‍💻 Xcode 16.0+

New in Xcode 16, testing your Swift code has never been easier.

Whether you are starting a new project, or already have an existing project with existing XCTests, there is not much you have to do to start using Swift Testing as it is interoperable with existing XCTest cases.

Let's create a testable object to help guide this post, how about a standard BankAccount:

final class BankAccount {
    private(set) var balance: Double

    init(initialBalance: Double = 0.0) {
        balance = initialBalance
    }

    // Deposit money into the account
    func deposit(amount: Double) {
        guard amount > 0 else { return }
        balance += amount
    }

    // Withdraw money from the account
    func withdraw(amount: Double) {
        guard amount > 0 else { return }
        balance -= amount
    }
}

Great! Now let's test our BankAccount class.

The easiest way to get started is by simply creating a new file in your test target, and adding a test using the @Test macro:

import Testing

@Test func deposit() {
    let bankAccount = BankAccount()
    #expect(bankAccount.balance == 0)
    
    bankAccount.deposit(amount: 100)
    #expect(bankAccount.balance == 100)
}

That's it! You've created your first test to check that our deposit function works, and it does.

Previously, in XCTest, we had to create a subclass of XCTestCase, but this is no longer the case. Your file can now contain test functions at the root level, it doesn't really matter where you define functions.

For more code clarity and organization, you can group your test case inside a simple struct like so:

import Testing

struct BankAccountTests {
    // tests in here
}

This automatically creates a test suite called BankAccountTests.

Let's add another test:

struct BankAccountTests {
    @Test func deposit() {
        let bankAccount = BankAccount()
        #expect(bankAccount.balance == 0)
        
        bankAccount.deposit(amount: 100)
        #expect(bankAccount.balance == 100)
    }
    
    @Test func withdraw() {
        let bankAccount = BankAccount()
        #expect(bankAccount.balance == 0)
        
        bankAccount.withdraw(amount: 100)
        #expect(bankAccount.balance == 0)
    }
}

As you can see in my test above, I don't want to be able to overdraw my account, and our second test here will fail.

So let's rethink our BankAccount object:

final class BankAccount {
    enum Error: Swift.Error {
        case insufficientFunds
    }
    
    private(set) var balance: Double

    init(initialBalance: Double = 0.0) {
        balance = initialBalance
    }

    // Deposit money into the account
    func deposit(amount: Double) {
        guard amount > 0 else { return }
        balance += amount
    }

    // Withdraw money from the account
    func withdraw(amount: Double) throws {
        guard amount > 0 else { return }

        if amount > balance {
            throw Error.insufficientFunds
        } else {
            balance -= amount
        }
    }
}

Now we can rewrite our tests:

struct BankAccountTests {
    @Test func deposit() {
        let bankAccount = BankAccount()
        #expect(bankAccount.balance == 0)
        
        bankAccount.deposit(amount: 100)
        #expect(bankAccount.balance == 100)
    }
    
    @Test func withdrawFails() {
        let bankAccount = BankAccount()
        #expect(bankAccount.balance == 0)
        
        #expect(throws: BankAccount.Error.insufficientFunds) {
            try bankAccount.withdraw(amount: 100)
        }
    }
    
    @Test func withdrawSucceeds() {
        let bankAccount = BankAccount(initialBalance: 100)
        #expect(bankAccount.balance == 100)
        
        #expect(throws: Never.self) {
            try bankAccount.withdraw(amount: 100)
        }
    }
}

Where to go from here

These are really simple use cases of Swift Testing, but the testing package is quite powerful and offers many avenues of customization.

Stay tuned and subscribe to the newsletter for more upcoming posts about Swift Testing!