Swift Actor in Unit Tests

Abdul Moiz
Thumbtack Engineering
9 min readJul 3, 2024

Introduction

Earlier in 2024, the iOS teams at Thumbtack were working to migrate to Xcode 15.3 to take advantage of all of the new features Apple’s latest IDE offers (such as Swift 5.10). During this migration we ran into issues related to concurrency/multi-threading. Namely, Swift’s mechanism for detecting race conditions at the compilation level changed and required us to rearchitect portions of our app to support this change. This post outlines our approach to addressing these challenges by adopting Swift Actors, which ensure atomic access to their properties and methods, thereby enhancing thread safety. Additionally, we adopted a new design pattern that facilitates the integration of Swift Actors into our existing unit testing frameworks. If you want to know more about how Swift Actors work and how we were able to incorporate them into our app, check it out in the following sections.

Problem

The iOS team at Thumbtack wanted to adopt Swift concurrency in our codebase, which is one of Swift’s newest ways of handling asynchronous code and is baked directly into the Swift programming language with added safety checks. Recently, we made our codebase compliant with targeted Swift concurrency checking. As of Swift 6, default checking would be complete. Immutable and value type instances like struct can safely cross actor boundaries because there is no risk of triggering race conditions. Classes, on the other hand, must conform to the Sendable protocol in order to cross actor boundaries. Conforming to Sendable, ensures that the class either cannot be mutated in any way, or that any potential mutations are guaranteed to be thread safe through internal locking mechanisms. The warnings generated during a compilation in targeted mode can be suppressed by annotating the class with @unchecked Sendable, but doing so requires the developer to ensure that the class has a proper locking mechanism for reading and writing data concurrently. Async/Await was working fine for the iOS team at Thumbtack until we began developing against Xcode 15.3, at which point the iOS team at Thumbtack started seeing Swift concurrency warnings.

“Passing argument of non-sendable type <class> outside of main actor-isolated context may introduce data races”

Most of these warnings are due to us calling our singleton network class from other places which are not compliant with the sendable protocol. So to make it comply with sendable, we had to mark this as a final class and have it conform with the Sendable protocol or simply bypass these warnings by annotating it with @unchecked Sendable. Both these options have their own hurdles.

Marking it as a final class with sendable requires us to convert all properties used in that class to also comply with sendables, which is not possible due to third party library usages. Unless those libraries comply, we are stuck with these warnings. Another issue is we cannot subclass these classes in our unit tests, so we cannot really mock network responses and it would break our unit tests.

Marking it as @unchecked Sendable requires us to implement our own locking mechanism and we would also need to make it a non-singleton class, as singleton would lock each API call and prevent multiple calls at the same time. Another drawback is @unchecked Sendable is sub-classable which means any subclass would need to implement a manual locking mechanism. Going with this approach meant we were reinventing the wheel.

The way to move ahead and work around these warnings was to use actor based network implementation. Swift Actor provides atomic access to its properties and methods and prevents data races right at the compilation level. Actor based network class would cause each call to its methods/properties to be isolated which would protect the data access from other actors or threads. Hence no more data races and we get locking mechanisms out of the box.

The only problem that remains is the actor does not support inheritance and our unit test suffers the same fate as in the case for final class complying with sendables — as we would not be able to mock actors in the unit tests. Unit test framework also wouldn’t compile and it would also break our code integration tool.

In the following section, we will dive into the solution of utilizing an actor and mocking it in unit tests.

Stubbing at a glance

Before we get started with the solution, for the time being assume we have the following functions available in our codebase that can inject and stub dependencies. This will help in understanding the examples below.

public protocol Injectables {
init()
}

fileprivate var stubs: [ObjectIdentifier: Any] = [:]

// Return a new instance or a mock instance when stubbed.
public func inject<T: Injectables>(_ type: T.Type) -> T {
let identifier = ObjectIdentifier(type)
if let instance = stubs[identifier] as? T {
return instance
}
return type.init()
}

// Takes a type and stores that object in global stubs.
public func stub<T: Injectables>(_ type: T.Type, with object: T) {
let identifier = ObjectIdentifier(type)
stubs[identifier] = object
}

We have globally available inject and stub methods. Inject can either return a new instance or mock instance. Stub can be used to insert mock instances.

Overcoming the Problem

To solve this issue and utilize actors, we can work around protocols — as they can serve as a common ground for multiple actors complying to the same protocol. This way we can pass actors during object initialization or inject it. We don’t prefer passing objects during initialization as this requires a workaround solution to pass one object from one class to another if they are deeply nested in the workflow. It would also bring forth an increased amount of effort to work this way. So let’s try to inject it and be smart about it.

In the code example below, we added a NetworkServiceActorProtocol which conforms to Actor and Injectables protocol and underlying actor implementations were complying to the NetworkServiceActorProtocol.

public protocol NetworkServiceActorProtocol: Actor, Injectables {
func fetch(query: String) async throws -> Data
}

public actor NetworkServiceActor: NetworkServiceActorProtocol {
init() {}

func fetch(query: String) async throws -> Data {
//... some implementation
}
}

// Tests.swift file
public actor NetworkServiceActorMock: NetworkServiceActorProtocol {
init() {}

func fetch(query: String) async throws -> Data {
//... some implementation
}
}

We can inject this the following way:

let networkServiceActor: NetworkServiceActorProtocol = inject(NetworkServiceActor.self)

Now that we have a start, the next question is, “how can we stub the mock in the unit tests?”

We cannot stub NetworkServiceActor with NetworkServiceActorMock as both are independent actors and we can’t pass protocol type either as that cannot be initialized.

First attempt through dynamic actor stubbing

So far we have two independent actors, one in production code and another in unit tests. None of those are dynamically injectable. This means we cannot stub NetworkServiceActorMock over NetworkServiceActor — as our code sees them as independent actors. However, we can swizzle the injection and inject a mock in the unit tests. We add a new dynamic static instance method to the protocol definition so that we can swizzle the instance method in the unit tests. Our code so far…

public protocol NetworkServiceActorProtocol: Actor, Injectables {
dynamic static func instance() -> NetworkServiceActorProtocol
//... Rest of the code
}

public actor NetworkServiceActor: NetworkServiceActorProtocol {
public dynamic static func instance() -> any NetworkServiceActorProtocol {
inject(NetworkServiceActor.self)
}
//... Rest of the code
}

// Tests.swift file
public actor NetworkServiceActorMock: NetworkServiceActorProtocol {
public dynamic static func instance() -> any NetworkServiceActorProtocol {
inject(NetworkServiceActorMock.self)
}
//... Rest of the code
}

In order to method swizzle, Swift has a private attribute @_dynamicReplacement(of: <method name>) that can be used to change the underlying implementation of dynamic functions at runtime. (Note: method_exchangeImplementation is objective-c based method swizzling and cannot be used with Actor protocol as they are non-objc compliant and marking then @objc would result in compilation error.)

In the unit test framework we are going to add a NetworkServiceActor extension, which will have a new method testInstance, which is bound to return NetworkServiceActorMock and it will swizzle the original instance method.

// Tests.swift file
public extension NetworkServiceActor {
@_dynamicReplacement(for: instance)
static func testInstance() → any NetworkServiceActorProtocol {
NetworkServiceActorMock.instance()
}
}

Now we can use the injection and stub the mock in the following way:

let networkServiceActor: NetworkServiceActorProtocol = NetworkServiceActor.instance()

// Tests.swift file
let networkMock = NetworkServiceActorMock()
stub(NetworkServiceActorMock.self, with: networkMock)

In production code it will return NetworkServiceActor whereas in the unit test it will return NetworkServiceActorMock because of method swizzling since we are using the protocol as the base it would suffice in both production and test code. This solves the problem but it has drawbacks of its own:

  1. The approach is not generalized. Adding a different actor would require us to do the same effort again.
  2. It adds a learning curve for developers to inject actors differently and to stub the mock actor rather than the actual actor.

The two drawbacks mentioned above caused us to move toward our next section which deals with the generalized way of mocking and stubbing actors.

Generalized way of actor stubbing

We can generalize our solution with associated protocol types. We make all actors comply with some base protocol and use it for stubbing. For this reason we added a default ActorProtocol which complies to Swift Actor protocol and all our actors now comply with ActorProtocol. It contains an associatedtype ProtocolType that is the key to this magic, as we use this ProtocolType to stub and return mock instances as any protocol rather than concrete type.

public protocol ActorProtocol: Actor {
associatedtype ProtocolType
init()
}

public protocol NetworkServiceActorProtocol: ActorProtocol where ProtocolType == any NetworkServiceActorProtocol {
func fetch(query: String) async throws -> Data
}

public actor NetworkServiceActor: NetworkServiceActorProtocol {
init() {}

func fetch(query: String) async throws -> Data {
//... some implementation
}
}

// Tests.swift file
public actor NetworkServiceActorMock: NetworkServiceActorProtocol {
init() {}

func fetch(query: String) async throws -> Data {
//... some implementation
}
}

So far both our NetworkServiceActor and NetworkServiceActorMock both comply with NetworkServiceActorProtocol where associatedType ProtocolType is set to any NetworkServiceActorProtocol.

Now to the actual dependency injection and stubbing implementation itself. We have in our codebase our own dependency injection implementation which contains:

  1. A private stubs variable to hold mocks and a stub method to set mocks in the stubs variable.
  2. A get method which we use to return mock instances or initialize a new instance of provided type.
  3. Globally available inject and stub functions which are interacting with Injector singleton.
public final class Injector {
static var shared = Injector()

private var stubs: [ObjectIdentifier: Any] = [:]

private init() {}

fileprivate func get<T: ActorProtocol>(_ type: T.Type) -> T.ProtocolType {
let identifier = ObjectIdentifier(type)

if let instance = stubs[identifier] as? T.ProtocolType {
return instance
}

return type.init() as! T.ProtocolType
}

fileprivate func stub<T: ActorProtocol, U: ActorProtocol>(
_ type: T.Type,
with object: U
) where T.ProtocolType == U.ProtocolType {
let identifier = ObjectIdentifier(type)
stubs[identifier] = object
}
}

public func inject<T: ActorProtocol>(_ type: T.Type? = nil) -> T.ProtocolType {
Injector.shared.get(T.self)
}

public func stub<T: ActorProtocol, U: ActorProtocol>(
_ type: T.Type,
with object: U
) where T.ProtocolType == U.ProtocolType {
Injector.shared.stub(T.self, with: object)
}

As you can see in above implementation stubbing is done smartly, we are passing two different actors on a condition that their ProtocolType must be the same. So we use the first type as an object identifier and the second as the mock object.

Injecting the code is also simple as we are passing a concrete type complying with ActorProtocol which is used as an object identifier to look up mock instances and return them or initialize a new instance of that type since both our actors have the same ProtocolType.

Now we can use the injection and stub the mock the following way which is similar syntax with our other injection types.

let networkServiceActor = inject(NetworkServiceActor.self)

// Tests.swift file
let networkMock = NetworkServiceActorMock()
stub(NetworkServiceActor.self, with: networkMock)

Conclusion

Adopting Swift actors in our codebase enabled us to resolve Swift concurrency issues. It enabled us to migrate to Xcode 15.3. Modifying the codebase to inject/stub actors also allowed us to keep utilizing our unit/snapshot test frameworks. We now have a generalized approach that is extendable to any other actor and we can easily mock them in test frameworks.

Additional Reading

  1. Swift concurrency (Link)
  2. What are sendables? (Link)
  3. What is an actor? (Link)
  4. Actor isolation (Link)
  5. Swift dynamic replacement (Link)
  6. Associated type protocols (Link)

--

--