如何在 XCTestCase 的 viewModel 中正确测试通过发布者更改的 var
How do I properly test a var that changes through a publisher in my viewModel in XCTestCase
我正在尝试在 Combine 框架和 SwiftUI 中测试一个简单的发布者。我的测试在我的视图模型中测试了一个名为 isValid 的已发布布尔值。我的视图模型还有一个已发布的用户名字符串,当更改并变为 3 个字符或更多字符时,会为 isValid 分配值。这是视图模型。我确定我不了解发布者在测试环境中的工作方式、时间安排等……提前致谢。
public class UserViewModel: ObservableObject {
@Published var username = ""
@Published var isValid = false
private var disposables = Set<AnyCancellable>()
init() {
$username
.receive(on: RunLoop.main)
.removeDuplicates()
.map { input in
print("~~~> \(input.count >= 3)")
return input.count >= 3
}
.assign(to: \.isValid, on: self)
.store(in: &disposables)
}
}
这是我的看法,这里不是很重要
struct ContentView: View {
@ObservedObject private var userViewModel = UserViewModel()
var body: some View {
TextField("Username", text: $userViewModel.username)
}
}
这是我的测试文件和失败的单个测试
class WhosebugQuestionTests: XCTestCase {
var model = UserViewModel()
override func setUp() {
model = UserViewModel()
}
override func tearDown() {
}
func testIsValid() {
model.username = "1"
XCTAssertFalse(model.isValid)
model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
}
}
原因是视图模型异步但测试是同步的...
$username
.receive(on: RunLoop.main)
... 这里的 .receive
运算符在 RunLoop.main
的下一个事件循环中对 isValid
进行最终赋值
但是考试
model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
预计isValid
会立即更改。
所以有以下可能的解决方案:
完全删除 .receive
运算符(在这种情况下更可取,因为它是 UI 工作流,无论如何总是在主运行循环中,所以使用计划接收是多余的。
$username
.removeDuplicates()
.map { input in
print("~~~> \(input.count >= 3)")
return input.count >= 3
}
.assign(to: \.isValid, on: self)
.store(in: &disposables)
结果:
model.username = "1234"
XCTAssertTrue(model.isValid) // << PASSED
让 UT 等待一个事件,然后才测试 isValid
(在这种情况下,应该记录 isValid
故意具有异步性质)
model.username = "1234"
RunLoop.main.run(mode: .default, before: .distantPast) // << wait one event
XCTAssertTrue(model.isValid) // << PASSED
正如@Asperi 所说:这个错误的原因是您接收的值是异步的。我搜索了一下,发现 Apple's tutorial about XCTestExpectation
usage. So I tried to use it with your code and the tests passed successfully. The other way is to use Combine Expectations.
class WhosebugQuestionTests: XCTestCase {
var model = UserViewModel()
override func setUp() {
model = UserViewModel()
}
func testIsValid() throws {
let expectation = self.expectation(description: "waiting validation")
let subscriber = model.$isValid.sink { _ in
guard self.model.username != "" else { return }
expectation.fulfill()
}
model.username = "1234"
wait(for: [expectation], timeout: 1)
XCTAssertTrue(model.isValid)
}
func testIsNotValid() {
let expectation = self.expectation(description: "waiting validation")
let subscriber = model.$isValid.sink { _ in
guard self.model.username != "" else { return }
expectation.fulfill()
}
model.username = "1"
wait(for: [expectation], timeout: 1)
XCTAssertFalse(model.isValid)
}
}
更新
为了清楚起见,我添加了所有代码和输出。我像您的示例中那样更改了测试验证(您在其中测试“1”和“1234”选项)。你会看到,我只是复制粘贴你的模型(除了变量的名称和 public
和 init()
)。但是,我没有这个错误:
Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "waiting validation".
// MARK: TestableCombineModel.swift file
import Foundation
import Combine
public class TestableModel: ObservableObject {
@Published public var username = ""
@Published public var isValid = false
private var disposables = Set<AnyCancellable>()
public init() {
$username
.receive(on: RunLoop.main) // as you see, I didn't delete it
.removeDuplicates()
.map { input in
print("~~~> \(input.count >= 3)")
return input.count >= 3
}
.assign(to: \.isValid, on: self)
.store(in: &disposables)
}
}
// MARK: WhosebuganswerTests.swift file:
import XCTest
import Whosebuganswer
import Combine
class WhosebuganswerTests: XCTestCase {
var model: TestableModel!
override func setUp() {
model = TestableModel()
}
func testValidation() throws {
let expectationSuccessfulValidation = self.expectation(description: "waiting successful validation")
let expectationFailedValidation = self.expectation(description: "waiting failed validation")
let subscriber = model.$isValid.sink { _ in
// look at the output. at the first time there will be "nothing"
print(self.model.username == "" ? "nothing" : self.model.username)
if self.model.username == "1234" {
expectationSuccessfulValidation.fulfill()
} else if self.model.username == "1" {
expectationFailedValidation.fulfill()
}
}
model.username = "1234"
wait(for: [expectationSuccessfulValidation], timeout: 1)
XCTAssertTrue(model.isValid)
model.username = "1"
wait(for: [expectationFailedValidation], timeout: 1)
XCTAssertFalse(model.isValid)
}
}
这是输出
2020-01-14 09:16:41.207649+0600 Whosebuganswer[1266:46298] Launching with XCTest injected. Preparing to run tests.
2020-01-14 09:16:41.389610+0600 Whosebuganswer[1266:46298] Waiting to run tests until the app finishes launching.
Test Suite 'All tests' started at 2020-01-14 09:16:41.711
Test Suite 'WhosebuganswerTests.xctest' started at 2020-01-14 09:16:41.712
Test Suite 'WhosebuganswerTests' started at 2020-01-14 09:16:41.712
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' started.
nothing
~~~> true
1234
~~~> false
1
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' passed (0.004 seconds).
Test Suite 'WhosebuganswerTests' passed at 2020-01-14 09:16:41.717.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'WhosebuganswerTests.xctest' passed at 2020-01-14 09:16:41.717.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'All tests' passed at 2020-01-14 09:16:41.718.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.006) seconds
UPDATE 2 实际上,如果我更改了这行代码,我确实会发现“异步等待失败:...”的错误:
let subscriber = model.$isValid.sink { _ in
对此,Xcode提议:
model.$isValid.sink { _ in // remove "let subscriber ="
我一直在使用 Combine Testing Extensions 来帮助进行 Publisher 测试,代码看起来相当不错:
// ARRANGE
let showAlert = viewModel.$showAlert.record(numberOfRecords: 2)
// ACT
viewModel.doTheThing()
// ASSERT
let records = showAlert.waitAndCollectRecords()
XCTAssertEqual(records, [.value(false), .value(true)])
我正在尝试在 Combine 框架和 SwiftUI 中测试一个简单的发布者。我的测试在我的视图模型中测试了一个名为 isValid 的已发布布尔值。我的视图模型还有一个已发布的用户名字符串,当更改并变为 3 个字符或更多字符时,会为 isValid 分配值。这是视图模型。我确定我不了解发布者在测试环境中的工作方式、时间安排等……提前致谢。
public class UserViewModel: ObservableObject {
@Published var username = ""
@Published var isValid = false
private var disposables = Set<AnyCancellable>()
init() {
$username
.receive(on: RunLoop.main)
.removeDuplicates()
.map { input in
print("~~~> \(input.count >= 3)")
return input.count >= 3
}
.assign(to: \.isValid, on: self)
.store(in: &disposables)
}
}
这是我的看法,这里不是很重要
struct ContentView: View {
@ObservedObject private var userViewModel = UserViewModel()
var body: some View {
TextField("Username", text: $userViewModel.username)
}
}
这是我的测试文件和失败的单个测试
class WhosebugQuestionTests: XCTestCase {
var model = UserViewModel()
override func setUp() {
model = UserViewModel()
}
override func tearDown() {
}
func testIsValid() {
model.username = "1"
XCTAssertFalse(model.isValid)
model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
}
}
原因是视图模型异步但测试是同步的...
$username
.receive(on: RunLoop.main)
... 这里的 .receive
运算符在 RunLoop.main
isValid
进行最终赋值
但是考试
model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
预计isValid
会立即更改。
所以有以下可能的解决方案:
完全删除
.receive
运算符(在这种情况下更可取,因为它是 UI 工作流,无论如何总是在主运行循环中,所以使用计划接收是多余的。$username .removeDuplicates() .map { input in print("~~~> \(input.count >= 3)") return input.count >= 3 } .assign(to: \.isValid, on: self) .store(in: &disposables)
结果:
model.username = "1234"
XCTAssertTrue(model.isValid) // << PASSED
让 UT 等待一个事件,然后才测试
isValid
(在这种情况下,应该记录isValid
故意具有异步性质)model.username = "1234" RunLoop.main.run(mode: .default, before: .distantPast) // << wait one event XCTAssertTrue(model.isValid) // << PASSED
正如@Asperi 所说:这个错误的原因是您接收的值是异步的。我搜索了一下,发现 Apple's tutorial about XCTestExpectation
usage. So I tried to use it with your code and the tests passed successfully. The other way is to use Combine Expectations.
class WhosebugQuestionTests: XCTestCase {
var model = UserViewModel()
override func setUp() {
model = UserViewModel()
}
func testIsValid() throws {
let expectation = self.expectation(description: "waiting validation")
let subscriber = model.$isValid.sink { _ in
guard self.model.username != "" else { return }
expectation.fulfill()
}
model.username = "1234"
wait(for: [expectation], timeout: 1)
XCTAssertTrue(model.isValid)
}
func testIsNotValid() {
let expectation = self.expectation(description: "waiting validation")
let subscriber = model.$isValid.sink { _ in
guard self.model.username != "" else { return }
expectation.fulfill()
}
model.username = "1"
wait(for: [expectation], timeout: 1)
XCTAssertFalse(model.isValid)
}
}
更新
为了清楚起见,我添加了所有代码和输出。我像您的示例中那样更改了测试验证(您在其中测试“1”和“1234”选项)。你会看到,我只是复制粘贴你的模型(除了变量的名称和 public
和 init()
)。但是,我没有这个错误:
Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "waiting validation".
// MARK: TestableCombineModel.swift file
import Foundation
import Combine
public class TestableModel: ObservableObject {
@Published public var username = ""
@Published public var isValid = false
private var disposables = Set<AnyCancellable>()
public init() {
$username
.receive(on: RunLoop.main) // as you see, I didn't delete it
.removeDuplicates()
.map { input in
print("~~~> \(input.count >= 3)")
return input.count >= 3
}
.assign(to: \.isValid, on: self)
.store(in: &disposables)
}
}
// MARK: WhosebuganswerTests.swift file:
import XCTest
import Whosebuganswer
import Combine
class WhosebuganswerTests: XCTestCase {
var model: TestableModel!
override func setUp() {
model = TestableModel()
}
func testValidation() throws {
let expectationSuccessfulValidation = self.expectation(description: "waiting successful validation")
let expectationFailedValidation = self.expectation(description: "waiting failed validation")
let subscriber = model.$isValid.sink { _ in
// look at the output. at the first time there will be "nothing"
print(self.model.username == "" ? "nothing" : self.model.username)
if self.model.username == "1234" {
expectationSuccessfulValidation.fulfill()
} else if self.model.username == "1" {
expectationFailedValidation.fulfill()
}
}
model.username = "1234"
wait(for: [expectationSuccessfulValidation], timeout: 1)
XCTAssertTrue(model.isValid)
model.username = "1"
wait(for: [expectationFailedValidation], timeout: 1)
XCTAssertFalse(model.isValid)
}
}
这是输出
2020-01-14 09:16:41.207649+0600 Whosebuganswer[1266:46298] Launching with XCTest injected. Preparing to run tests.
2020-01-14 09:16:41.389610+0600 Whosebuganswer[1266:46298] Waiting to run tests until the app finishes launching.
Test Suite 'All tests' started at 2020-01-14 09:16:41.711
Test Suite 'WhosebuganswerTests.xctest' started at 2020-01-14 09:16:41.712
Test Suite 'WhosebuganswerTests' started at 2020-01-14 09:16:41.712
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' started.
nothing
~~~> true
1234
~~~> false
1
Test Case '-[WhosebuganswerTests.WhosebuganswerTests testValidation]' passed (0.004 seconds).
Test Suite 'WhosebuganswerTests' passed at 2020-01-14 09:16:41.717.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'WhosebuganswerTests.xctest' passed at 2020-01-14 09:16:41.717.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.005) seconds
Test Suite 'All tests' passed at 2020-01-14 09:16:41.718.
Executed 1 test, with 0 failures (0 unexpected) in 0.004 (0.006) seconds
UPDATE 2 实际上,如果我更改了这行代码,我确实会发现“异步等待失败:...”的错误:
let subscriber = model.$isValid.sink { _ in
对此,Xcode提议:
model.$isValid.sink { _ in // remove "let subscriber ="
我一直在使用 Combine Testing Extensions 来帮助进行 Publisher 测试,代码看起来相当不错:
// ARRANGE
let showAlert = viewModel.$showAlert.record(numberOfRecords: 2)
// ACT
viewModel.doTheThing()
// ASSERT
let records = showAlert.waitAndCollectRecords()
XCTAssertEqual(records, [.value(false), .value(true)])