首页 文章

在ViewController中单元测试RxSwift可观察

提问于
浏览
4

我'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 调用以下方法来设置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 在更改事件后 searchTextField 少于2个字符时设置为 nil .
我尝试了几种不同的方法 . 我的测试目前看起来像这样:

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 )上手动/编程触发事件以触发标记为"This is the part I want to test:"的第二个订阅中的代码 .

3 回答

  • 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 中的方法 .

  • 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测试更适合 .

    请注意,此示例省略:

    • 视图模型中网络层的依赖注入 .

    • 视图控制器的textField值绑定到 query (即 textField.rx.text.asDriver().drive(viewModel.query) ) .

    • 视图控制器观察结果变量(即 viewModel.results.asObservable.subscribe(/* ... */) ) .

    这里可能有一些拼写错误,没有通过编译器运行它 .

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

    现在,让's think about what we actually need to test here. For my part, I don' t感觉需要测试 self?.results = nilself.searchTextField.rx.text ,以便可以忽略View控制器进行测试 .

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

相关问题