在 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)
效果只是源和接收器(另一个需要注意效果的地方是链中的任何flatMap
s。)所以让我们将它们分开:
(我把它放在一个扩展中,因为我知道大多数人讨厌免费功能)
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 = nil
或self.searchTextField.rx.text
,因此可以忽略视图控制器进行测试。
所以这只是一个测试操作符的问题......最近有一篇很棒的文章:https://www.raywenderlich.com/7408-testing-your-rxswift-code 但是,坦率地说,我在这里没有看到任何需要测试的东西。我可以相信 throttle
、map
和 filter
可以按设计工作,因为它们在 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的主要内容,如果未能解决你的问题,请参考以下文章