Shawn Flahave
Shawn Flahave

Reputation: 516

Unit-test RxSwift observable in ViewController

I'm quite new to RxSwift. I have a view controller that has a typeahead/autocomplete feature (i.e., user types in a UITextField and as soon as they enter at least 2 characters a network request is made to search for matching suggestions). The controller's viewDidLoad calls the following method to set up an Observable:

class TypeaheadResultsViewController: UIViewController {

var searchTextFieldObservable: Observable<String>!
@IBOutlet weak var searchTextField: UITextField!
private let disposeBag = DisposeBag()
var results: [TypeaheadResult]?

override func viewDidLoad() {
    super.viewDidLoad()
    //... unrelated setup stuff ...
    setupSearchTextObserver()
}

func setupSearchTextObserver() {
            searchTextFieldObservable =
                self.searchTextField
                    .rx
                    .text
                    .throttle(0.5, scheduler: MainScheduler.instance)
                    .map { $0 ?? "" }

            searchTextFieldObservable
                .filter { $0.count >= 2 }
                .flatMapLatest { searchTerm in self.search(for: searchTerm) }
                .subscribe(
                    onNext: { [weak self] searchResults in
                        self?.resetResults(results: searchResults)
                    },
                    onError: { [weak self] error in
                        print(error)
                        self?.activityIndicator.stopAnimating()
                    }
                )
                .disposed(by: disposeBag)

            // This is the part I want to test:        
            searchTextFieldObservable
                .filter { $0.count < 2 }
                .subscribe(
                    onNext: { [weak self] _ in
                        self?.results = nil
                    }
                )
                .disposed(by: disposeBag)
    }
}

This seems to work fine, but I'm struggling to figure out how to unit test the behavior of searchTextFieldObservable. To keep it simple, I just want a unit test to verify that results is set to nil when searchTextField has fewer than 2 characters after a change event.
I have tried several different approaches. My test currently looks like this:

    class TypeaheadResultsViewControllerTests: XCTestCase {
        var ctrl: TypeaheadResultsViewController!

        override func setUp() {
            super.setUp()
            let storyboard = UIStoryboard(name: "MainStoryboard", bundle: nil)
            ctrl = storyboard.instantiateViewController(withIdentifier: "TypeaheadResultsViewController") as! TypeaheadResultsViewController
        }

        override func tearDown() {
            ctrl = nil
            super.tearDown()
        }

        /// Verify that the searchTextObserver sets the results array
        /// to nil when there are less than two characters in the searchTextView
        func testManualChange() {
          // Given: The view is loaded (this triggers viewDidLoad)
          XCTAssertNotNil(ctrl.view)
          XCTAssertNotNil(ctrl.searchTextField)
          XCTAssertNotNil(ctrl.searchTextFieldObservable)

          // And: results is not empty
          ctrl.results = [ TypeaheadResult(value: "Something") ]

          let tfObservable = ctrl.searchTextField.rx.text.subscribeOn(MainScheduler.instance)
          //ctrl.searchTextField.rx.text.onNext("e")
          ctrl.searchTextField.insertText("e")
          //ctrl.searchTextField.text = "e"
          do {
              guard let result =
                try tfObservable.toBlocking(timeout: 5.0).first() else { 
return }
            XCTAssertEqual(result, "e")  // passes
            XCTAssertNil(ctrl.results)  // fails
        } catch {
            print(error)
        }
    }

Basically, I'm wondering how to manually/programmatically fire an event on searchTextFieldObservable (or, preferably, on the searchTextField) to trigger the code in the 2nd subscription marked "This is the part I want to test:".

Upvotes: 5

Views: 3454

Answers (4)

Fabio Mignogna
Fabio Mignogna

Reputation: 164

If you look at the underlying implementation for rx.text, you'll see it relies on controlPropertyWithDefaultEvents which fires the following UIControl events: .allEditingEvents and .valueChanged.

Simply setting the text, it won't fire any events, so your observable is not triggered. You have to send an action explicitly:

textField.text = "Something"
textField.sendActions(for: .valueChanged) // or .allEditingEvents

If you are testing within a framework, sendActions won't work because the framework is missing the UIApplication. You can do this instead

extension UIControl {
    func simulate(event: UIControl.Event) {
        allTargets.forEach { target in
            actions(forTarget: target, forControlEvent: event)?.forEach {
                (target as NSObject).perform(Selector($0))
            }
        }
    }
}

...

textField.text = "Something"
textField.simulate(event: .valueChanged) // or .allEditingEvents

Upvotes: 0

Daniel T.
Daniel T.

Reputation: 33967

The first step is to separate the logic from the effects. Once you do that, it will be easy to test your logic. In this case, the chain you want to test is:

self.searchTextField.rx.text
.throttle(0.5, scheduler: MainScheduler.instance)
.map { $0 ?? "" }
.filter { $0.count < 2 }
.subscribe(
    onNext: { [weak self] _ in
        self?.results = nil
    }
)
.disposed(by: disposeBag)

The effects are only the source and the sink (another place to look out for effects is in any flatMaps in the chain.) So lets separate them out:

(I put this in an extension because I know how much most people hate free functions)

extension ObservableConvertibleType where E == String? {
    func resetResults(scheduler: SchedulerType) -> Observable<Void> {
        return asObservable()
            .throttle(0.5, scheduler: scheduler)
            .map { $0 ?? "" }
            .filter { $0.count < 2 }
            .map { _ in }
    }
}

And the code in the view controller becomes:

self.searchTextField.rx.text
    .resetResults(scheduler: MainScheduler.instance)
    .subscribe(
        onNext: { [weak self] in
            self?.results = nil
        }
    )
    .disposed(by: disposeBag)

Now, let's think about what we actually need to test here. For my part, I don't feel the need to test self?.results = nil or self.searchTextField.rx.text so the View controller can be ignored for testing.

So it's just a matter of testing the operator... There's a great article that recently came out: https://www.raywenderlich.com/7408-testing-your-rxswift-code However, frankly I don't see anything that needs testing here. I can trust that throttle, map and filter work as designed because they were tested in the RxSwift library and the closures passed in are so basic that I don't see any point in testing them either.

Upvotes: 1

CloakedEddy
CloakedEddy

Reputation: 1995

I prefer to keep UIViewControllers far away from my unit tests. Therefore, I suggest moving this logic to a view model.

As your bounty explanation details, basically what you are trying to do is mock the textField's text property, so that it fires events when you want it to. I would suggest replacing it with a mock value altogether. If you make textField.rx.text.bind(viewModel.query) the responsibility of the view controller, then you can focus on the view model for the unit test and manually alter the query variable as needed.

class ViewModel {
   let query: Variable<String?> = Variable(nil)
   let results: Variable<[TypeaheadResult]> = Variable([])

   let disposeBag = DisposeBag()

   init() {
     query
       .asObservable()
       .flatMap { query in 
          return query.count >= 2 ? search(for: $0) : .just([])
       }
       .bind(results)
       .disposed(by: disposeBag)
   }

   func search(query: String) -> Observable<[TypeaheadResult]> {
     // ...
   }
}

The test case:

class TypeaheadResultsViewControllerTests: XCTestCase {

    func testManualChange() {
      let viewModel = ViewModel()
      viewModel.results.value = [/* .., .., .. */]

      // this triggers the subscription, but does not trigger the search
      viewModel.query.value = "1"

      // assert the results list is empty
      XCTAssertEqual(viewModel.results.value, [])
   }
}

If you also want to test the connection between the textField and the view model, UI tests are a much better fit.

Note that this example omits:

  1. Dependency injection of the network layer in the view model.
  2. The binding of the view controller's textField value to query (i.e., textField.rx.text.asDriver().drive(viewModel.query)).
  3. The observing of the results variable by the view controller (i.e., viewModel.results.asObservable.subscribe(/* ... */)).

There might be some typos in here, did not run it past the compiler.

Upvotes: 0

kiwisip
kiwisip

Reputation: 447

The problem is that self.ctrl.searchTextField.rx.text.onNext("e") won't trigger searchTextFieldObservable onNext subscription.

The subscription is also not triggered if you set the text value directly like this self.ctrl.searchTextField.text = "e".

The subscription will trigger (and your test should succeed) if you set the textField value like this: self.ctrl.searchTextField.insertText("e").

I think the reason for this is that UITextField.rx.text observes methods from UIKeyInput.

Upvotes: 0

Related Questions