从单元测试探讨 MVC to MVVM 的差异
你在这里学到什么?
用 RxSwift DataBinding从 MVC 业务逻辑抽离比较 MVC 与 MVVM Unit test 的差异但是以上的内容我都是带过,不会花太时间解释
我们的注意力会放在单元测试上。
如何开始
目标
这个是目前View的画面,目前还没有套用任何逻辑。
目标是让输入匡输入5个字元,就算是符合规範。
帐号与密码都符合规範,Login in 的按钮才可以按。
Get start
读取客製化的View
这是目前的view Controller,什么东西都没有。
class LoginPageViewController:UIViewController{}
先在 LoadView 读取客製化的view 。
class LoginPageViewController:UIViewController{ var loginPageView:LoginPageView! //MARK: - LoadView() override func loadView() { loginPageView = LoginPageView() self.view = loginPageView }}
很好,已经读到画面了。
但是还是没办法有逻辑上的连动
DataBinding
我把binding的过程分开,当然你可以写在一起。
为了refacter方便我会分开写。
//MARK: - DataBinding() func dataBinding(){ //observable //vaild //bind }}
创建Obserable
这里我创建了两个推送事件序列,这两个推送的物件是 UITextField的text属性。
//MARK: - DataBinding() func dataBinding(){ //observable let usernameUITextFieldObservable = loginPageView.usernameTextField.rx.text.orEmpty let passnameUITextFieldObservable = loginPageView.passwordTestField.rx.text.orEmpty //vaild let usernameVaild = usernameUITextFieldObservable .map{ $0.count >= minimalUsernameLength} let passwordVaild = passnameUITextFieldObservable .map{ $0.count >= minimalPasswordLength} let everythingVaild = Observable.combineLatest(usernameVaild,passwordVaild) .map{ $0 && $1 } //bind }
创建业务逻辑
这里有三道业务逻辑。
依照usernameUITextFieldObservable的字数传递Boolean依照passnameUITextFieldObservable的字数传递Boolean依照usernameVaild与passwordVaild传递的Boolean 依照 AND运算子 传递 Boolean//MARK: - DataBinding() func dataBinding(){ //observable let usernameUITextFieldObservable = loginPageView.usernameTextField.rx.text.orEmpty let passnameUITextFieldObservable = loginPageView.passwordTestField.rx.text.orEmpty //vaild let usernameVaild = usernameUITextFieldObservable .map{ $0.count >= minimalUsernameLength} let passwordVaild = passnameUITextFieldObservable .map{ $0.count >= minimalPasswordLength} let everythingVaild = Observable.combineLatest(usernameVaild,passwordVaild) .map{ $0 && $1 } //bind usernameVaild.bind(to: loginPageView.usernameValidUILabel.rx.isHidden) .disposed(by: disposeBag) passwordVaild.bind(to: loginPageView.passwordValidUILabel.rx.isHidden) .disposed(by: disposeBag) everythingVaild.bind(to: loginPageView.loginButton.rx.isEnabled) .disposed(by: disposeBag) }
绑定
对应要做出反应的参数。
这边要注意 dispose 的回收机制。
有兴趣可以参考autoreleasepool,这是相同的回收机制。
//MARK: - DataBinding() func dataBinding(){ //observable let usernameUITextFieldObservable = loginPageView.usernameTextField.rx.text.orEmpty let passnameUITextFieldObservable = loginPageView.passwordTestField.rx.text.orEmpty //vaild let usernameVaild = usernameUITextFieldObservable .map{ $0.count <= minimalUsernameLength} let passwordVaild = passnameUITextFieldObservable .map{ $0.count <= minimalPasswordLength} let everythingVaild = Observable.combineLatest(usernameVaild,passwordVaild) .map{ $0 && $1 } //bind usernameVaild.bind(to: loginPageView.usernameValidUILabel.rx.isHidden) .disposed(by: disposeBag) usernameVaild.bind(to: loginPageView.passwordValidUILabel.rx.isHidden) .disposed(by: disposeBag) everythingVaild.bind(to: loginPageView.loginButton.rx.isEnabled) .disposed(by: disposeBag) }
以上我们已经完成了资料绑定,已经可以做互动了。
MVC 业务逻辑测试
我们从一个测试类别开始
class MVCLearnTests: XCTestCase {}
我们配置好 sut <-- 受测试的物件
class MVCLearnTests: XCTestCase { var sut : LoginPageViewController! override func setUp() { super.setUp() sut = LoginPageViewController() } override func tearDown() { super.tearDown() sut = nil }}
建议善用 setUp 与 tearUp 的回收机制。避免因为没有清除影响其他测试。
延伸阅读: zombie objects 。
基本的配置完成后,可以开始写测试的函式了。
func testLoginPageViewController_whenUsernameIsVaild_usernameVaildUIlabelisEnable(){ //given //when //then }
先写好三个测试流程的步骤:
这是为了方便建制这个 Test的流程。
Given 在特定的条件下
When 当某个行为发生时
Then 预期要发生的结果
延伸阅读:命名规範
func testLoginPageViewController_whenUsernameIsVaild_usernameVaildUIlabelisEnable(){ //given let text = "12345" //when sut.loginPageView.usernameTextField.text = text //then let isEnabled = sut.loginPageView.usernameTextField.isEnabled XCTAssertEqual(isEnabled, true) }
测试逻辑写完后 command + u 测试看看。
结果发生问题了,这是为什么呢?
因为我们要测试的物件牵涉到UI
因此我们要实例化UI的物件。
我们是在 LoadView() 实例化物件的。所以我们让 sut 执行 LoadView()
override func setUp() { super.setUp() sut = LoginPageViewController() sut.loadView() }
command + u 再测试一次。
测试成功了
在这里我们注意到两件事:
Unit test 本身是不牵涉到 view 的生命週期我们为了测试业务逻辑,却把view拖到这个浑水了(实例化了view)MVC to MVVM
我们从一个空白的class开始。
class LoginPageViewModel{}
然后把刚刚 vaild 的片段(业务逻辑)贴过来。
然后稍作改写一下 viewModel 就成形了
class LoginPageViewModel{ var usernameVaild:Observable<Bool> var passwordVaild:Observable<Bool> var everythingVaild:Observable<Bool> init (username:Observable<String>,password:Observable<String>){ //vaild usernameVaild = username .map{ $0.count >= minimalUsernameLength} passwordVaild = password .map{ $0.count >= minimalPasswordLength} everythingVaild = Observable.combineLatest(usernameVaild,passwordVaild) .map{ $0 && $1 } }}
接下来把业务逻辑抽离。
import UIKitimport RxCocoaimport RxSwiftlet minimalUsernameLength = 5let minimalPasswordLength = 5class LoginPageViewController:UIViewController{ var loginPageView:LoginPageView! var disposeBag:DisposeBag! var viewModel : LoginPageViewModel! //MARK: - LoadView() override func loadView() { loginPageView = LoginPageView() self.view = loginPageView }//MARK: - ViewDidLoad() override func viewDidLoad() { super.viewDidLoad() disposeBag = DisposeBag() dataBinding() }//MARK: - DataBinding() func dataBinding(){ //observable viewModel = LoginPageViewModel( username: loginPageView.usernameTextField.rx.text.orEmpty.asObservable(), password: loginPageView.passwordTestField.rx.text.orEmpty.asObservable()) //bind viewModel.usernameVaild.bind(to: loginPageView.usernameValidUILabel.rx.isHidden) .disposed(by: disposeBag) viewModel.passwordVaild.bind(to: loginPageView.passwordValidUILabel.rx.isHidden) .disposed(by: disposeBag) viewModel.everythingVaild.bind(to: loginPageView.loginButton.rx.isEnabled) .disposed(by: disposeBag) }}
执行一下专案,可以正常运作。
这样MVVM已经算是完成了,我们来对他做测试吧。
MVVM 业务逻辑测试
配置好业务逻辑需要的基本设定
class LoginPageViewModelTests: XCTestCase { var sut : LoginPageViewModel! var usernameObservable:Observable<String>! var passwordObservable:Observable<String>! var disposeBag:DisposeBag! override func setUp() { super.setUp() disposeBag = DisposeBag() } override func tearDown() { super.tearDown() sut = nil usernameObservable = nil passwordObservable = nil disposeBag = nil }}
配置完成后可以开始写测试函式了
func testLoginPageViewModel_usernameIsValid_true(){ //given //when //then }
测试流程的注解。
func testLoginPageViewModel_usernameIsValid_true(){ //given usernameObservable = Observable.create({ (observer) -> Disposable in observer.onNext("12345") observer.onCompleted() return Disposables.create() }) passwordObservable = Observable.create({ (observer) -> Disposable in observer.onCompleted() return Disposables.create() }) //when sut = LoginPageViewModel( username: usernameObservable, password: passwordObservable) //then sut.usernameVaild.bind { (bool) in XCTAssertEqual(bool, true) }.disposed(by: disposeBag) }
command + u
测试成功
总结
1. MVC的单元测试必须实例化View。
MVC在单元测试时,必须要实例化view(MVC变着不纳入讨论),这使单元测试偏离了原生单元测试的设计。因为单元测试就应该测试业务逻辑,他不关心UI上面的变化。
2. MVVM 只是 MVC refactor 的过程。
MVVM 与 MVC 的差异就是把业务逻辑抽离出来,这让单元测试上有很大的帮助,我可以更专注在业务逻辑上的测试,而不用担心View的生命週期。