17

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
    }

}
user1302387
  • 278
  • 2
  • 11

3 Answers3

18

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
    
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 2
    I also ran into an issue with testing where I needed to write a test where the publisher used `.debounce(for: 0.5, scheduler: RunLoop.main)`. I ended up using your second method like this `RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(3))` and it worked. Thank you! – user1302387 Jan 12 '20 at 23:49
  • Exactly what i needed ! In my case combine was plugged into legacy synchronous code and just waiting for an expectation wasn't working – thibaut noah Dec 22 '21 at 08:58
  • @user1302387 I have spent several hour searching for the right way to deal with this. .debounce was causing all sorts of weird issues inside my testing. This was also the solution! – zypherman Sep 02 '22 at 18:10
7

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 ="
Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36
  • I tried what you have mentioned here and I am getting an error on line `wait(for: [expectation], timeout: 1)`. It is telling me _Asynchronous wait failed: Exceeded timeout of 1 seconds, with unfulfilled expectations: "waiting validation"._ . I just did a complete copy and paste of your code for clarity's sake. In the viewModel, I ran the tests with `.receive(on: RunLoop.main)` commented out and left in, and it still doesn't work. The error changes with that line included or not. I am assuming we want to remove `.receive(on: RunLoop.main)`. Any thoughts? – user1302387 Jan 13 '20 at 22:05
  • @user1302387 interesting and unexpected behavior. Please, check updates, maybe there you'll find the answer. But as you can see, I didn't change the model and didn't touch the ```.receive(on: RunLoop.main)``` line of code. At the and you'll see, how did I catch error with async and where was wrong line of code (maybe you followed Xcode's propose too). – Hrabovskyi Oleksandr Jan 14 '20 at 03:31
  • @ Александр I'm still not getting this working using this method. I error out with `caught "NSInternalInconsistencyException", "API violation - multiple calls made to -[XCTestExpectation fulfill] ` at the first fulfillment. It looks like it is happening twice. My output looks different than yours as well. Maybe my setup isn't like yours. – user1302387 Jan 14 '20 at 18:06
  • @user1302387 yes, while you're using ```.sink``` it calls for 3 times (you saw my output, there is "nothing" at the first time). Ok, let's check Xcode versions: I tried it at 11.2 (11B52). And even more: I wrote such kind of tests for my project example (at https://github.com/LexHrabovskyi/SimplePlayer). It's interesting for me, why this code doesn't work in your example. Maybe there is some kind of chat at StackOverflow? – Hrabovskyi Oleksandr Jan 15 '20 at 04:00
  • @ Александр I uploaded this test project to my Google drive. Take a look at it if you want. I am wondering if my setup is at fault. I am on 11.3.1. Thanks for your interest and help here. https://drive.google.com/open?id=1CXmppqORErlxoAMZty4LOEBUZAEkuui3 – user1302387 Jan 15 '20 at 19:05
  • @user1302387 It's time to update my Xcode and look, how 11.3.1 works. Again, I downloaded your project, ran it on Xcode version 11.2 and all the tests passed. – Hrabovskyi Oleksandr Jan 16 '20 at 02:31
0

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)])
RefuX
  • 490
  • 5
  • 11