在 ViewController 中可观察到的单元测试 RxSwift

Posted

技术标签:

【中文标题】在 ViewController 中可观察到的单元测试 RxSwift【英文标题】:Unit-test RxSwift observable in ViewController 【发布时间】:2018-02-18 20:00:07 【问题描述】:

我对 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,而 searchTextField 在更改事件后的字符数少于 2 个。 我尝试了几种不同的方法。我的测试目前看起来像这样:

    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)上触发一个事件,以触发第二个订阅中标记为“这是我要测试的部分”的代码:"。

【问题讨论】:

如果你删除searchTextFieldObservable中的throttle操作符,你的测试是否成功? @kiwisip - 否 - 结果相同。似乎该事件从未被触发。也就是说,我在onNext处理程序的第一行设置了一个断点,它永远不会被命中(除了第一次加载视图时)。 你应该使用 RxBlocking 来测试这个异步任务。测试写得不太好...您应该看到gitbook.com/book/orta/pragmatic-ios-testing/details 以编写更好的测试。如果您对此进行调试,您会看到内部某处的 onNext 被异步调用,并且当 XCTAssert 已经完成时仍可以继续运行。 我已经更新了测试代码以显示现在的实际测试代码,而不是伪代码 【参考方案1】:

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

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)

效果只是源和接收器(另一个需要注意效果的地方是链中的任何flatMaps。)所以让我们将它们分开:

(我把它放在一个扩展中,因为我知道大多数人讨厌免费功能)

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,因此可以忽略视图控制器进行测试。

所以这只是一个测试操作符的问题......最近有一篇很棒的文章:https://www.raywenderlich.com/7408-testing-your-rxswift-code 但是,坦率地说,我在这里没有看到任何需要测试的东西。我可以相信 throttlemapfilter 可以按设计工作,因为它们在 RxSwift 库中进行了测试,并且传入的闭包非常基础,我也看不出测试它们有任何意义。

【讨论】:

【参考方案2】:

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

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

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

我认为这是因为UITextField.rx.text 观察到来自UIKeyInput 的方法。

【讨论】:

我尝试使用self.ctrl.searchTextField.insertText("e"),但这似乎仍然没有触发事件。 如果你现在删除throttle 不幸的是,删除 throttle 并不能解决问题。我已经用我现在的 real 测试代码更新了我的问题...... 如果你写类似DispatchQueue.main.asynchAfter(deadline: .now() + 0.5, execute: XCTAssertNil(ctrl.results) ) .. 会失败吗? insertText 为我工作,所以这真的很奇怪.. 在操场上尝试一下,你会发现它有效【参考方案3】:

我更喜欢让 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 测试更适合。

请注意,此示例省略:

    视图模型中网络层的依赖注入。 视图控制器的 textField 值绑定到query(即textField.rx.text.asDriver().drive(viewModel.query))。 视图控制器对结果变量的观察(即viewModel.results.asObservable.subscribe(/* ... */))。

这里可能有错别字,没有通过编译器运行。

【讨论】:

【参考方案4】:

如果您查看rx.text 的底层实现,您会发现它依赖于controlPropertyWithDefaultEvents,而fires 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

【讨论】:

以上是关于在 ViewController 中可观察到的单元测试 RxSwift的主要内容,如果未能解决你的问题,请参考以下文章

使用表数据中可观察到的剔除值来应用 CSS 类

如何对这个从管道可观察到的错误捕获的 Angular 打字稿 Http 错误拦截器进行单元测试?

R中可扩展的结转以创建每日时间序列

侧面可观察数组中可观察数组的变化不更新淘汰赛js中的UI

如何捕获测试中可观察订阅抛出的异常

实例被解除分配,而键值观察者仍向其注册