iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🕵️

Creating Mocks for Actor-Only Protocols with nonisolated Members (Swift)

に公開

Actor-only protocols

Consider the following ControllerProtocol.

ControllerProtocol.swift
protocol ControllerProtocol: Actor {
    func someFunc1()
    nonisolated func someFunc2()
}

Because this ControllerProtocol conforms to Actor, it becomes an actor-only protocol.

✅ ControllerProtocol is an actor-only protocol
actor Controller: ControllerProtocol {
    func someFunc1() { /* ... */ }
    nonisolated func someFunc2() { /* ... */ }
}

Screenshot showing a compilation error when trying to apply ControllerProtocol, an actor-only protocol, to a class
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.

ControllerMock.swift
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().

ControllerMock.swift
  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.

SomeTests.swift
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().

Screenshot showing compilation errors when trying to prepare someFunc2CallCount and someFunc2Handler for someFunc2(), similar to what was done 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?

Screenshot showing a compilation error when applying the nonisolated keyword to a stored property

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().

Screenshot of code where isolated properties inside someFunc2() are modified asynchronously from within a Task

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.

Screenshot of test results where the test for someFunc2() was run 100 times, succeeding twice and failing 98 times

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.

SomeTests.swift
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].

SomeTests.swift
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)  // ✅
    }
    
    /* ... */
}
脚注
  1. The term "computed property" is based on the translation in The Swift Programming Language Japanese Version · Properties. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. The term "stored property" is based on the translation in The Swift Programming Language Japanese Version · Properties. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  3. 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