在ViewController中单元测试RxSwift可观察

时间:2018-02-18 20:00:07

标签: ios swift unit-testing rx-swift

我对RxSwift很新。我有一个具有预先输入/自动完成功能的视图控制器(即,用户在UITextField中键入,并且一旦输入至少2个字符,就会发出网络请求以搜索匹配的建议)。控制器的viewDidLoad调用以下方法来设置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)
    }
}

这似乎工作正常,但我正在努力弄清楚如何单元测试 searchTextFieldObservable的行为。 为了简单起见,我只想要一个单元测试来确认results在更改事件后nil少于2个字符时设置为searchTextField
我尝试了几种不同的方法。我的测试目前看起来像这样:

    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)
        }
    }

基本上,我想知道如何在searchTextFieldObservable(或者,最好是在searchTextField)上手动/编程方式触发事件,以触发标记为“这是部分的第二个订阅中的代码”我想测试:“。

4 个答案:

答案 0 :(得分:0)

问题在于self.ctrl.searchTextField.rx.text.onNext("e")不会触发searchTextFieldObservable onNext订阅。

如果您直接像self.ctrl.searchTextField.text = "e"一样设置文本值,也不会触发订阅。

如果您将textField值设置为self.ctrl.searchTextField.insertText("e"),则订阅将触发(并且测试应该成功)。

我认为原因是UITextField.rx.text会观察UIKeyInput的方法。

答案 1 :(得分:0)

我更喜欢让UIViewControllers远离我的单元测试。因此,我建议将此逻辑移至视图模型。

正如您的赏金说明详细说明,基本上您要做的是模拟textField的text属性,以便在您需要时触发事件。我建议完全用模拟值替换它。如果让textField.rx.text.bind(viewModel.query)负责视图控制器,那么您可以专注于单元测试的视图模型,并根据需要手动更改查询变量。

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]> {
     // ...
   }
}

测试用例:

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, [])
   }
}

如果您还想测试textField和视图模型之间的连接,那么UI测试更适合。

请注意,此示例省略:

  1. 视图模型中网络层的依赖注入。
  2. 将视图控制器的textField值绑定到query(即textField.rx.text.asDriver().drive(viewModel.query))。
  3. 视图控制器观察结果变量(即viewModel.results.asObservable.subscribe(/* ... */))。
  4. 这里可能有一些拼写错误,没有通过编译器运行。

答案 2 :(得分:0)

第一步是将逻辑与效果分开。一旦这样做,将很容易测试您的逻辑。在这种情况下,您要测试的链为:

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)

效果仅是源和汇(另一个要注意效果的地方是链中的任何flatMap中。)因此,将它们分开:

(我把它放在扩展名中,因为我知道大多数人讨厌自由功能)

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 }
    }
}

并且视图控制器中的代码变为:

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

现在,让我们考虑一下我们实际需要在此处进行测试的内容。就我而言,我觉得不需要测试self?.results = nilself.searchTextField.rx.text,因此可以忽略View控制器进行测试。

因此,这只是测试操作员的问题...最近发表了一篇很棒的文章:https://www.raywenderlich.com/7408-testing-your-rxswift-code但是,坦率地说,我在这里看不到任何需要测试的内容。我可以相信throttlemapfilter可以按设计工作,因为它们已在RxSwift库中进行了测试,并且传入的闭包非常基本,以至于我在测试中看不到任何意义他们也是。

答案 3 :(得分:0)

如果您查看 rx.text 的底层实现,您会发现它依赖于 controlPropertyWithDefaultEventsfires the following UIControl events: .allEditingEvents and .valueChanged

只需设置文本,它不会触发任何事件,因此不会触发您的 observable。您必须明确发送一个动作:

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

如果您在框架内进行测试,sendActions 将不起作用,因为该框架缺少 UIApplication。你可以这样做

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