对依赖于异步函数的类进行单元测试

问题描述 投票:0回答:3

我有一个带有状态属性枚举的视图模型,有 3 个案例。

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
    }
}

我想编写一个单元测试,断言在调用

load
之后但在异步函数返回之前, state == .loading 。

如果我使用完成处理程序,我可以创建一个实现 ServiceType 的间谍,捕获该完成处理程序但不调用它。如果我使用组合,我可以使用计划来控制执行。

使用 Swift 新的并发模型是否有等效的解决方案?

swift async-await continuations swift-concurrency
3个回答
2
投票

当您通过协议注入依赖项时,您处于非常有利的位置,可以为该协议提供一个 Fake,一个您可以通过单元测试完全控制的 Fake:

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) }
    }
}

完成上述操作后,您的单元测试就可以完全控制:它们知道何时/是否调用

doSomething
,并且可以指示函数应如何响应。

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)
    }
}

上述测试确保

doSomething()
被调用,因为您可能不想验证视图模型状态,直到您确定
load()
的执行到达预期位置 - 毕竟,
load()
是在不同的线程,因此我们需要一个期望来确保测试正确等待,直到线程执行达到预期点。

上述技术与模拟/存根非常相似,其中实现被替换为提供的单元测试。您甚至可以更进一步,只使用异步闭包而不是基于连续的闭包:

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

    func doSomething() async {
        doSomethingReply()
    }
}

,虽然这会给单元测试带来更大的控制,但它也增加了在这些单元测试上创建延续的负担。


0
投票

我最近也遇到了类似的问题,你可以在单元测试中使用新的 async/wait 方式。

final class ViewModelTest: XCTestCase {

   var sut: ViewModel!

   override func setUp() {
       sut = ViewModel(service: MockService())
   }

   override func tearDownWithError() throws {
       sut = nil
   }

   @MainActor
   func testLoadingState() async throws {
      //Check initial loading state
       XCTAssertEqual(sut.state, .notLoaded)
       //
       let task = Task {  await sut.load() }

       // Yield the above task to ensure it's constructed and finished.
       await Task.yield()
       //
       //Loading state value must be .loading
       XCTAssertTrue(sut.state == .loading, "Loading state should update")
       //
       await task.value
       //Then
       XCTAssertFalse(sut.state == .loaded)
   }

}

-1
投票

您可以像处理完成处理程序一样处理此问题,您可以选择使用

doSomething
 延迟 
Task.sleep(nanoseconds:)
的完成,或者您可以使用继续来永远阻止执行,而不像下面那样恢复它你正在使用完成处理程序。

所以你的延迟测试场景的模拟

ServiceType
看起来像:

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

或者对于永远暂停的场景:

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
        }
    }
}
© www.soinside.com 2019 - 2024. All rights reserved.