iTranslated by AI
Creating Mocks for Actor-Only Protocols with nonisolated Members (Swift)
Actor-only protocols
Consider the following ControllerProtocol.
protocol ControllerProtocol: Actor {
func someFunc1()
nonisolated func someFunc2()
}
Because this ControllerProtocol conforms to Actor, it becomes an actor-only protocol.
actor Controller: ControllerProtocol {
func someFunc1() { /* ... */ }
nonisolated func someFunc2() { /* ... */ }
}

Attempting to attach an actor-only protocol to a class, struct, or enum results in a compilation error.
Creating mockups for testing actor-only protocols
Furthermore, let's consider creating a mockup of this ControllerProtocol for testing purposes.
actor ControllerMock: ControllerProtocol {
/* ... */
}
In this case, we will create a mockup that satisfies the following two requirements:
- The number of times a function is called is counted and can be retrieved externally.
- The logic to be executed when the function is called can be provided externally.
Enabling tracking call counts and providing logic for isolated functions
First, we will prepare the isolated function someFunc1().
actor ControllerMock: ControllerProtocol {
+ // MARK: - `someFunc1()`
+ private(set) var someFunc1CallCount = 0
+ private var someFunc1Handler: (() -> ())?
+ func set(someFunc1Handler: (@Sendable () -> ())?) {
+ self.someFunc1Handler = someFunc1Handler
+ }
+ func someFunc1() {
+ someFunc1CallCount += 1
+ someFunc1Handler?()
+ }
/* ... */
}
When someFunc1() is called, the value of someFunc1CallCount increases by 1. Additionally, the logic previously provided via set(someFunc1Handler:) is executed.
import XCTest
final class SomeTests: XCTestCase {
let mock = ControllerMock()
func testSomeFunc1() async {
await mock.set(someFunc1Handler: { print("Implementation for someFunc1 provided externally") })
// Assuming this mock is injected somewhere and someFunc1() is called through it
await mock.someFunc1() // Calling someFunc1() directly here for brevity
let someFunc1CallCount = await mock.someFunc1CallCount
XCTAssertEqual(someFunc1CallCount, 1) // ✅
}
/* ... */
}
When you run this testSomeFunc1(), you can confirm that Implementation for someFunc1 provided externally is output to the console and someFunc1CallCount becomes 1.
Enabling tracking call counts and providing logic for isolated computed properties[1]
For computed properties
You can do the same for computed properties[1:1].
protocol ControllerProtocol: Actor {
/* ... */
var someProperty1: String { get }
/* ... */
}
actor ControllerMock: ControllerProtocol {
/* ... */
// MARK: - `someProperty1`
private(set) var someProperty1CallCount = 0
private var someProperty1Handler: (() -> String)!
func set(someProperty1Handler: @escaping @Sendable () -> String) {
self.someProperty1Handler = someProperty1Handler
}
var someProperty1: String {
someProperty1CallCount += 1
return someProperty1Handler()
}
/* ... */
}
final class SomeTests: XCTestCase {
let mock = ControllerMock()
/* ... */
func testSomeProperty1() async {
await mock.set(someProperty1Handler: { "someProperty1 provided externally" })
// Assuming this mock is injected somewhere and someProperty1 is accessed through it
let someProperty1 = await mock.someProperty1 // Calling someProperty1 directly here for brevity
XCTAssertEqual(someProperty1, "someProperty1 provided externally")
let someProperty1CallCount = await mock.someProperty1CallCount
XCTAssertEqual(someProperty1CallCount, 1)
}
/* ... */
}
Enabling tracking call counts and providing logic for nonisolated functions
Now, let's move on to the main topic: preparing the nonisolated function someFunc2(). Let's try to set up someFunc2CallCount and someFunc2Handler just as we did for the isolated function someFunc1().

This results in compilation errors: Actor-isolated property 'someFunc2CallCount' can not be mutated from a non-isolated context and Actor-isolated property 'someFunc2Handler' can not be referenced from a non-isolated context. Since someFunc2CallCount and someFunc2Handler are isolated to the actor, they cannot be modified or referenced from within the nonisolated function someFunc2().
So, should we also make someFunc2CallCount and someFunc2Handler nonisolated?

Now we get a different compilation error: 'nonisolated' can not be applied to stored properties. This is because the nonisolated keyword cannot be applied to stored properties[2].
Leaving the stored properties[2:1] as they are, let's try changing the implementation inside someFunc2().

I tried creating a Task inside someFunc2() to asynchronously modify and call the actor-isolated someFunc2CallCount and someFunc2Handler.
The compilation errors are gone, so let's try running this test 100 times in a row.

Looking at the test results, it succeeded only 2 times and failed 98 times. Since someFunc2() returns Void (completes) before the operation inside the Task finishes, someFunc2CallCount might still be 0 instead of 1 when retrieved, depending on the timing.
This makes it unsuitable as a mockup for testing.
For Swift 5.10 or later: Using nonisolated(unsafe)
Earlier, I mentioned that "nonisolated cannot be applied to stored properties," but you can use nonisolated(unsafe), which was introduced in Swift 5.10 via SE-0412, for stored properties[2:2].
actor ControllerMock: ControllerProtocol {
/* ... */
+ // MARK: - `someFunc2()`
+ private(set) nonisolated(unsafe) var someFunc2CallCount = 0
+ private nonisolated(unsafe) var someFunc2Handler: (@Sendable () -> ())?
+ nonisolated func set(someFunc2Handler: (@Sendable () -> ())?) {
+ self.someFunc2Handler = someFunc2Handler
+ }
+ nonisolated func someFunc2() {
+ someFunc2CallCount += 1
+ someFunc2Handler?()
+ }
/* ... */
}
As the title of SE-0412 suggests, nonisolated(unsafe) is an annotation intended for global variables. However, in this specific case, it compiles and runs correctly. Furthermore, since this is a mockup for testing, by narrowing the scope with appropriate access modifiers, we can likely tolerate the risk of properties marked with nonisolated(unsafe) being used in unintended ways.
import XCTest
final class SomeTests: XCTestCase {
let mock = ControllerMock()
/* ... */
func testSomeFunc2() {
mock.set(someFunc2Handler: { print("Implementation for someFunc2 provided externally") }) // The await keyword is not required because it is nonisolated
// Assuming this mock is injected somewhere and someFunc2() is called through it
mock.someFunc2() // Calling someFunc2() directly here for brevity
let someFunc2CallCount = mock.someFunc2CallCount // The await keyword is not required because it is nonisolated(unsafe)
XCTAssertEqual(someFunc2CallCount, 1) // ✅
}
/* ... */
}
When you run this testSomeFunc2(), you can confirm that Implementation for someFunc2 provided externally is output to the console and someFunc2CallCount becomes 1.
For versions before Swift 5.10: Moving properties to a non-isolated location
Earlier, I mentioned that "nonisolated cannot be applied to stored properties," but computed properties[1:2] can be made nonisolated. Utilizing this, we can consider a method where the properties we want to use in the mockup are turned into nonisolated computed properties[1:3], and their values are stored in a location that is not isolated to the actor.
actor ControllerMock: ControllerProtocol {
+ private struct NonIsolatedPropertyStorage {
+ static var shared = Self()
+ private init() {}
+ static func reset() { shared = Self() }
+
+ var someFunc2CallCount = 0
+ var someFunc2Handler: (@Sendable () -> ())?
+ }
+
+ init() {
+ NonIsolatedPropertyStorage.reset()
+ }
/* ... */
+ // MARK: - `someFunc2()`
+ private(set) nonisolated var someFunc2CallCount: Int {
+ get { NonIsolatedPropertyStorage.shared.someFunc2CallCount }
+ set { NonIsolatedPropertyStorage.shared.someFunc2CallCount = newValue }
+ }
+ private nonisolated var someFunc2Handler: (@Sendable () -> ())? {
+ get { NonIsolatedPropertyStorage.shared.someFunc2Handler }
+ set { NonIsolatedPropertyStorage.shared.someFunc2Handler = newValue }
+ }
+ nonisolated func set(someFunc2Handler: (@Sendable () -> ())?) {
+ self.someFunc2Handler = someFunc2Handler
+ }
+ nonisolated func someFunc2() {
+ someFunc2CallCount += 1
+ someFunc2Handler?()
+ }
/* ... */
}
The code above is an example of creating NonIsolatedPropertyStorage as a "location not isolated to the actor." The properties intended for use in the mockup are turned into nonisolated computed properties[1:4], which access the stored properties[2:3] within NonIsolatedPropertyStorage via getters and setters. The more properties you want to use in the mockup, the more the amount of required code increases[3].
import XCTest
final class SomeTests: XCTestCase {
let mock = ControllerMock()
/* ... */
func testSomeFunc2() {
mock.set(someFunc2Handler: { print("Implementation for someFunc2 provided externally") }) // The await keyword is not required because it is nonisolated
// Assuming this mock is injected somewhere and someFunc2() is called through it
mock.someFunc2() // Calling someFunc2() directly here for brevity
let someFunc2CallCount = mock.someFunc2CallCount // The await keyword is not required because it is nonisolated
XCTAssertEqual(someFunc2CallCount, 1) // ✅
}
/* ... */
}
When you run this testSomeFunc2(), you can confirm that Implementation for someFunc2 provided externally is output to the console and someFunc2CallCount becomes 1.
Enabling tracking call counts and providing logic for nonisolated computed properties[1:5]
For computed properties
You can do the same for computed properties[1:6].
protocol ControllerProtocol: Actor {
/* ... */
nonisolated var someProperty2: String { get }
/* ... */
}
For Swift 5.10 or later: Using nonisolated(unsafe)
actor ControllerMock: ControllerProtocol {
/* ... */
// MARK: - `someProperty2`
private(set) nonisolated(unsafe) var someProperty2CallCount = 0
private nonisolated(unsafe) var someProperty2Handler: (() -> String)!
nonisolated func set(someProperty2Handler: @escaping @Sendable () -> String) {
self.someProperty2Handler = someProperty2Handler
}
nonisolated var someProperty2: String {
someProperty2CallCount += 1
return someProperty2Handler()
}
/* ... */
}
final class SomeTests: XCTestCase {
let mock = ControllerMock()
/* ... */
func testSomeProperty2() {
mock.set(someProperty2Handler: { "someProperty2 provided externally" }) // The await keyword is not required because it is nonisolated
// Assuming this mock is injected somewhere and someProperty2 is accessed through it
let someProperty2 = mock.someProperty2 // Calling someProperty2 directly here for brevity
XCTAssertEqual(someProperty2, "someProperty2 provided externally")
let someProperty2CallCount = mock.someProperty2CallCount // The await keyword is not required because it is nonisolated(unsafe)
XCTAssertEqual(someProperty2CallCount, 1) // ✅
}
/* ... */
}
For versions before Swift 5.10: Moving properties to a non-isolated location
actor ControllerMock: ControllerProtocol {
private struct NonIsolatedPropertyStorage {
static var shared = Self()
private init() {}
static func reset() { shared = Self() }
var someProperty2CallCount = 0
var someProperty2Handler: (() -> String)!
}
init() {
NonIsolatedPropertyStorage.reset()
}
/* ... */
// MARK: - `someProperty2`
private(set) nonisolated var someProperty2CallCount: Int {
get { NonIsolatedPropertyStorage.shared.someProperty2CallCount }
set { NonIsolatedPropertyStorage.shared.someProperty2CallCount = newValue }
}
private nonisolated var someProperty2Handler: (() -> String)! {
get { NonIsolatedPropertyStorage.shared.someProperty2Handler }
set { NonIsolatedPropertyStorage.shared.someProperty2Handler = newValue }
}
nonisolated func set(someProperty2Handler: @escaping @Sendable () -> String) {
self.someProperty2Handler = someProperty2Handler
}
nonisolated var someProperty2: String {
someProperty2CallCount += 1
return someProperty2Handler()
}
/* ... */
}
final class SomeTests: XCTestCase {
let mock = ControllerMock()
/* ... */
func testSomeProperty2() {
mock.set(someProperty2Handler: { "someProperty2 provided externally" }) // The await keyword is not required because it is nonisolated
// Assuming this mock is injected somewhere and someProperty2 is accessed through it
let someProperty2 = mock.someProperty2 // Calling someProperty2 directly here for brevity
XCTAssertEqual(someProperty2, "someProperty2 provided externally")
let someProperty2CallCount = mock.someProperty2CallCount // The await keyword is not required because it is nonisolated
XCTAssertEqual(someProperty2CallCount, 1) // ✅
}
/* ... */
}
-
The term "computed property" is based on the translation in The Swift Programming Language Japanese Version · Properties. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
-
The term "stored property" is based on the translation in The Swift Programming Language Japanese Version · Properties. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
-
Since the properties used in the mockup and the stored properties[2:4] created in the "non-isolated location" correspond one-to-one, you could consider methods such as using Swift Macros to automatically generate code at compile time. ↩︎
Discussion