'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:

  1. 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
  1. 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
    

backup

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