'How do I properly test a var that changes through a publisher in my viewModel in XCTestCase
Im trying to test a simple publisher within the Combine framework and SwiftUI. My test tests a published bool named isValid in my view model. My view model also has a published username string, that when changes and becomes 3 characters or more isValid is assigned the value. Here is the view model. I am sure I am not understanding how publishers work in a test environment, timing etc... Thanks in advance.
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)
}
}
Here is my view, not really important here
struct ContentView: View {
@ObservedObject private var userViewModel = UserViewModel()
var body: some View {
TextField("Username", text: $userViewModel.username)
}
}
Here is my test file and single test that fails
class StackoverFlowQuestionTests: 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
}
}
Solution 1:[1]
The reason is that view model asynchronous but test is synchronous...
$username
.receive(on: RunLoop.main)
... the .receive
operator here makes final assignment of isValid
on the next event cycle of RunLoop.main
but the test
model.username = "1234"
XCTAssertTrue(model.isValid) //<----- THIS FAILS HERE
expects that isValid
will be changed immediately.
So there are following possible solutions:
remove
.receive
operator at all (in this case it is preferable, because it is UI workflow, which is anyway always on main runloop, so using scheduled receive is redundant.$username .removeDuplicates() .map { input in print("~~~> \(input.count >= 3)") return input.count >= 3 } .assign(to: \.isValid, on: self) .store(in: &disposables)
Result:
model.username = "1234"
XCTAssertTrue(model.isValid) // << PASSED
make UT wait for one event and only then test
isValid
(in this case it should be documented thatisValid
has asynchronous nature by intention)model.username = "1234" RunLoop.main.run(mode: .default, before: .distantPast) // << wait one event XCTAssertTrue(model.isValid) // << PASSED
Solution 2:[2]
As @Asperi said: the reason of this mistake is that you receive values asynchronous. I searched a little and found 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 StackoverFlowQuestionTests: 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)
}
}
UPDATE
I add all the code and output for clarity. I changed testing validation like in your example (where you test both "1" and "1234" options). And you'll see, that I just copy-paste your model (except name and public
for variables and init()
). But still, I don't have this mistake:
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: stackoverflowanswerTests.swift file:
import XCTest
import stackoverflowanswer
import Combine
class stackoverflowanswerTests: 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)
}
}
and here is the output
2020-01-14 09:16:41.207649+0600 stackoverflowanswer[1266:46298] Launching with XCTest injected. Preparing to run tests.
2020-01-14 09:16:41.389610+0600 stackoverflowanswer[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 'stackoverflowanswerTests.xctest' started at 2020-01-14 09:16:41.712
Test Suite 'stackoverflowanswerTests' started at 2020-01-14 09:16:41.712
Test Case '-[stackoverflowanswerTests.stackoverflowanswerTests testValidation]' started.
nothing
~~~> true
1234
~~~> false
1
Test Case '-[stackoverflowanswerTests.stackoverflowanswerTests testValidation]' passed (0.004 seconds).
Test Suite 'stackoverflowanswerTests' 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 'stackoverflowanswerTests.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 Actually I do catch mistakes of "Asynchronous wait failed: ..." if I changed this line of code:
let subscriber = model.$isValid.sink { _ in
to this, as Xcode propose:
model.$isValid.sink { _ in // remove "let subscriber ="
Solution 3:[3]
I've been using Combine Testing Extensions to help with Publisher testing and the code looks quite nice:
// ARRANGE
let showAlert = viewModel.$showAlert.record(numberOfRecords: 2)
// ACT
viewModel.doTheThing()
// ASSERT
let records = showAlert.waitAndCollectRecords()
XCTAssertEqual(records, [.value(false), .value(true)])
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
Solution | Source |
---|---|
Solution 1 | |
Solution 2 | |
Solution 3 | RefuX |