我对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
)上手动/编程方式触发事件,以触发标记为“这是部分的第二个订阅中的代码”我想测试:“。
答案 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测试更适合。
请注意,此示例省略:
query
(即textField.rx.text.asDriver().drive(viewModel.query)
)。viewModel.results.asObservable.subscribe(/* ... */)
)。这里可能有一些拼写错误,没有通过编译器运行。
答案 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 = nil
或self.searchTextField.rx.text
,因此可以忽略View控制器进行测试。
因此,这只是测试操作员的问题...最近发表了一篇很棒的文章:https://www.raywenderlich.com/7408-testing-your-rxswift-code但是,坦率地说,我在这里看不到任何需要测试的内容。我可以相信throttle
,map
和filter
可以按设计工作,因为它们已在RxSwift库中进行了测试,并且传入的闭包非常基本,以至于我在测试中看不到任何意义他们也是。
答案 3 :(得分:0)
如果您查看 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