DEV Community

Cover image for How to test code in Swift using actor
Igor Custodio
Igor Custodio

Posted on • Originally published at open.substack.com

How to test code in Swift using actor

Hey there, today I want to talk about unit testing on iOS and I'd like to start by discussing a different way to approach changes tracking in tests.

Recently, I ran into an issue while using Swift Testing where I didn't know how to wait for a state change. In XCTest, we would use expectations to track changes and validate scenarios.

While searching online, I found the confirmation function, which helps us track value changes. It works, but honestly, I found it a bit verbose - especially since it requires a completion block and nesting logic inside it. Swift Testing provides the confirmation function to help us deal with async flows, but it still feels very "XCTest-style" with manual fulfillment. Okay, it works and we can use it for sure.

But, what about a different and simpler approach?

Let me introduce you to actors and how they can help us in this scenario.

Why Actors are great for tracking state changes

From the documentation:
"By default, actors execute tasks on a shared global concurrency thread pool. This pool is shared by all default actors and tasks, unless an actor or task specified a more specific executor requirement."

And how is it useful for us?

We can leverage the concurrency features to wait for state changes in our code and then expect the values to be properly updated in a convenient way.

Let's see it in action:

Practical example

Consider the following View Model:

struct ViewModel {
    var onOpenURL: (URL) async -> Void

    func didTap(_ url: URL) async {
        await onOpenURL(url)
    }
}
Enter fullscreen mode Exit fullscreen mode

Once we call didTap passing a URL, we will call the onOpenUrl variable function to simulate a browser opening or any other asynchronous task.

In terms of testing, if we need to validate whether the URL was properly opened, we would use any confirmation or expectation to be able to validate it after the action. It would look like it for XCTest and Swift Testing.

In XCTest, we would have:

// Traditional XCTest-style test with expectation
func testOpenURL_withXCTestExpectation() async {
    let expectation = expectation(description: "URL opened")
    var recordedURL: String?

    let url = URL(string: "https://example.com")!
    let viewModel = ViewModel(onOpenURL: { url in
        recordedURL = url.absoluteString
        expectation.fulfill()
    })

    await viewModel.didTap(url)
    await fulfillment(of: [expectation])

    XCTAssertEqual(recordedURL, url.absoluteString)
}
Enter fullscreen mode Exit fullscreen mode

And, in Swift Testing:

@Test
func openURL_usingConfirmation() async {
    await confirmation() { c in
        var receivedUrl: String?

        let viewModel = ViewModel(onOpenURL: { url in
            receivedUrl = url.absoluteString
            c.confirm()
        })

        let url = URL(string: "https://example.com")!
        await viewModel.didTap(url)

        #expect(receivedUrl == url.absoluteString)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's jump into Actors and their superpowers. We still want to track the URL change inside the ViewModel and we can achieve it by using an Actor:

  1. Declare a new test and call the record function from the Actor where you would use expectation/confirmation instead.
actor URLTracker {
    private(set) var urlString: String?

    func record(_ url: URL) {
        urlString = url.absoluteString
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Declare a new test and call the record function from the Actor where you would use expectation/confirmation instead.
@Test
func openURL_withActor() async {
    let tracker = URLTracker()
    let url = URL(string: "https://example.com")!

    let viewModel = ViewModel(onOpenURL: { url in
        await tracker.record(url)
    })

    await viewModel.didTap(url)

    let recorded = await tracker.urlString
    #expect(recorded == url.absoluteString)
}
Enter fullscreen mode Exit fullscreen mode

And it's done!

This approach makes tests cleaner, easier to read, and more aligned with Swift's modern concurrency model.

Have you already used Actors for testing? Let me know in the comments!

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.