我有一个带有状态属性枚举的视图模型,有 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 新的并发模型是否有等效的解决方案?
当您通过协议注入依赖项时,您处于非常有利的位置,可以为该协议提供一个 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()
}
}
,虽然这会给单元测试带来更大的控制,但它也增加了在这些单元测试上创建延续的负担。
我最近也遇到了类似的问题,你可以在单元测试中使用新的 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)
}
}
您可以像处理完成处理程序一样处理此问题,您可以选择使用
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
}
}
}