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
}
}
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 ="
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 that isValid
has asynchronous nature by intention)
model.username = "1234"
RunLoop.main.run(mode: .default, before: .distantPast) // << wait one event
XCTAssertTrue(model.isValid) // << PASSED
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With