How to initialize Swift class annotated @MainActor for XCTest, SwiftUI Previews, etc
Asked Answered
N

2

8

We'd like to make use of the @MainActor Annotation for our ViewModels in an existing SwiftUI project, so we can get rid of DispatchQueue.main.async and .receive(on: RunLoop.main).

@MainActor
class MyViewModel: ObservableObject {
    private var counter: Int
    init(counter: Int) {
        self.counter = counter
    }
}

This works fine when initializing the annotated class from a SwiftUI View. However, when using a SwiftUI Previews or XCTest we also need to initialize the class from outside of the @MainActor context:

class MyViewModelTests: XCTestCase {

    private var myViewModel: MyViewModel!
    
    override func setUp() {
        myViewModel = MyViewModel(counter: 0)
    }

Which obviously doesn't compile:

Main actor-isolated property 'init(counter:Int)' can not be mutated from a non-isolated context

Now, obviously we could also annotate MyViewModelTests with @MainActor as suggested here.

But we don't want all our UnitTests to run on the main thread. So what is the recommended practice in this situation?

Annotating the init function with nonisolated as also suggested in the conversation above only works, if we don't want to set the value of variables inside the initializer.

Notional answered 3/11, 2022 at 12:55 Comment(3)
Why do you have view models in SwiftUI? That is what the View struct is for.Theca
Let's not turn this in an SwiftUI architecture discussion. There's good reasons to use View Structs for handling service/logic calls and there's good reasons to use ViewModels instead.Notional
I’ve not heard a good reasonTheca
B
5

NOTE: For Swift 6 use override func setUp() async

Just mark setUp() as @MainActor

class MyViewModelTests: XCTestCase {
    private var myViewModel: MyViewModel!

    @MainActor override func setUp() {
        myViewModel = MyViewModel(counter: 0)
    }
}
Bemoan answered 3/11, 2022 at 13:12 Comment(2)
That sure would work. However setUp() is called before every single test case, so there would still be a lot of work done one the main thread. There must be a better way?Notional
FWIW This is invalid in Swift 6 you'll get the error Main actor-isolated instance method 'setUp()' has different actor isolation from nonisolated overridden declaration; this is an error in the Swift 6 language mode. If you attempt the same with the async version of setUp it will complain that you're sending XCTestCase unless you mark your subclass @unchecked Sendable (likely also not preferred).Higbee
R
7

Approach:

  • You can use override func setUp() async throws instead

Model:

@MainActor
class MyViewModel: ObservableObject {
    var counter: Int
    
    init(counter: Int) {
        self.counter = counter
    }
    
    func set(counter: Int) {
        self.counter = counter
    }
}

Testcase:

import XCTest
@testable import Demo

final class MyViewModelTests: XCTestCase {
    private var myViewModel: MyViewModel!
    
    override func setUp() async throws {
        myViewModel = await MyViewModel(counter: 10)
    }
    
    override func tearDown() async throws {
        myViewModel = nil
    }

    func testExample() async throws {
        await myViewModel.set(counter: 20)
    }
}
Redshank answered 3/11, 2022 at 13:31 Comment(0)
B
5

NOTE: For Swift 6 use override func setUp() async

Just mark setUp() as @MainActor

class MyViewModelTests: XCTestCase {
    private var myViewModel: MyViewModel!

    @MainActor override func setUp() {
        myViewModel = MyViewModel(counter: 0)
    }
}
Bemoan answered 3/11, 2022 at 13:12 Comment(2)
That sure would work. However setUp() is called before every single test case, so there would still be a lot of work done one the main thread. There must be a better way?Notional
FWIW This is invalid in Swift 6 you'll get the error Main actor-isolated instance method 'setUp()' has different actor isolation from nonisolated overridden declaration; this is an error in the Swift 6 language mode. If you attempt the same with the async version of setUp it will complain that you're sending XCTestCase unless you mark your subclass @unchecked Sendable (likely also not preferred).Higbee

© 2022 - 2024 — McMap. All rights reserved.