3

I have a view model with a state property enum that has 3 cases.

protocol ServiceType {
    func doSomething() async
}

@MainActor
final class ViewModel {

    enum State {
        case notLoaded
        case loading
        case loaded
    }

    private let service: ServiceType
    var state: State = .notLoaded

    init(service: ServiceType) {
        self.service = service
    }

    func load() async {
        state = .loading
        await service.doSomething()
        state = .loaded
    }
}

I want to write a unit test that asserts that after load is called but before the async function returns, state == .loading .

If I was using completion handlers, I could create a spy that implements ServiceType, captures that completion handler but doesn't call it. If I was using combine I could use a schedular to control execution.

Is there an equivalent solution when using Swift's new concurrency model?

Darren Findlay
  • 2,533
  • 2
  • 29
  • 46
  • I'd use 'actor' instead of 'class' since you have a mutable state, and make 'state' private set. – cora Jun 14 '22 at 21:18
  • @cora - it’s already on main actor, so no need to change to an actor. But I agree re `private (set)`. – Rob Jun 14 '22 at 23:18
  • @cora thanks for the suggestions, this is simply a throw away example though to help illustrate the problem. – Darren Findlay Jun 15 '22 at 05:38

2 Answers2

1

As you're injecting the depencency via a protocol, you're in a very good position for providing a Fake for that protocol, a fake which you have full control from the unit tests:

class ServiceFake: ServiceType {
    var doSomethingReply: (CheckedContinuation<Void, Error>) -> Void = { _ in }

    func doSomething() async {
        // this creates a continuation, but does nothing with it
        // as it waits for the owners to instruct how to proceed
        await withCheckedContinuation { doSomethingReply($0) }
    }
}

With the above in place, your unit tests are in full control: they know when/if doSomething was called, and can instruct how the function should respond.

final class ViewModelTests: XCTestCase {
    func test_viewModelIsLoadingWhileDoSomethingSuspends() {
        let serviceFake = ServiceFake()
        let viewModel = ViewModel(service: serviceFake)

        XCTAssertEquals(viewModel.state, .notLoaded)

        let expectation = XCTestExpectation(description: "doSomething() was called")
        // just fulfilling the expectation, because we ignore the continuation
        // the execution of `load()` will not pass the `doSomething()` call 
        serviceFake.doSomethingReply = { _ in
            expectation.fulfill()
        }
        Task {
            viewModel.load()
        }
        wait(for: [expectation], timeout: 0.1)
        XCTAssertEqual(viewModel.state, .loading)
    }
}

The above test makes sure doSomething() is called, as you likely don't want to validate the view model state until you're sure the execution of load() reached the expected place - afterall, load() is called on a different thread, so we need an expectation to make sure the test properly waits until the thread execution reaches the expected point.

The above technique is very similar to a mock/stub, where the implementation is replaced with a unit-test provided one. You could even go further, and just have an async closure instead of a continuation-based one:

class ServiceFake: ServiceType {
    var doSomethingReply: () async -> Void = { }

    func doSomething() async {
        doSomethingReply()
    }
}

, and while this would give even greater control in the unit tests, it also pushes the burden of creating the continuations on those unit tests.

Cristik
  • 30,989
  • 25
  • 91
  • 127
  • ` let viewModel = ViewModel(service: serviceFake)` does not compile with error message "Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context" – Sébastien Stormacq Feb 22 '23 at 08:38
-1

You can handle this the similar way you were handling for completion handler, you have the choice to either delay the completion of doSomething using Task.sleep(nanoseconds:) or you can use continuation to block the execution forever by not resuming it same as you are doing with completion handler.

So your mock ServiceType for the delay test scenario looks like:

struct HangingSevice: ServiceType {
    func doSomething() async {
        let seconds: UInt64 = 1 // Delay by seconds
        try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
    }
}

Or for the forever suspended scenario:

class HangingSevice: ServiceType {
    private var continuation: CheckedContinuation<Void, Never>?

    deinit {
        continuation?.resume()
    }

    func doSomething() async {
        let seconds: UInt64 = 1 // Delay by seconds
        await withCheckedContinuation { continuation in
            self.continuation?.resume()
            self.continuation = continuation
        }
    }
}
Soumya Mahunt
  • 2,148
  • 12
  • 30