从单元测试探讨 MVC to MVVM 的差异

从单元测试探讨 MVC to MVVM 的差异

你在这里学到什麽?

  1. 用 RxSwift DataBinding
  2. 从 MVC 业务逻辑抽离
  3. 比较 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
    }

创建业务逻辑

这里有三道业务逻辑。

  1. 依照usernameUITextFieldObservable的字数传递Boolean
  2. 依照passnameUITextFieldObservable的字数传递Boolean
  3. 依照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
    }
}

建议善用 setUptearUp 的回收机制。避免因为没有清除影响其他测试。
延伸阅读: 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 再测试一次。

测试成功了

在这里我们注意到两件事:

  1. Unit test 本身是不牵涉到 view 的生命周期
  2. 我们为了测试业务逻辑,却把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 UIKit
import RxCocoa
import RxSwift


let minimalUsernameLength = 5
let minimalPasswordLength = 5

class 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的生命周期。

讨论

  1. 将业务逻辑拆开有很多方法,而MVVM仅仅是其中一种。
  2. DataBinding有哪些方法?

<<:  [Android Studio] -- Day 5 主题变换Theme02

>>:  解决line图文选单404问题

[Day21] 用 WASM 做一个凯萨密码 加密 / 解密 网站

那今天就把昨天的东西讲完吧 然後我觉得标题好难定 POPcat 这麽赞的内容竟然没人看 QQ 那这边...

爬虫怎麽爬 从零开始的爬虫自学 DAY4 开发环境-3 Visual Studio Code 使用设定

前言 各位早安,书接上回我们安装好python跟Visual Studio Code,完成了开发环境...

Day 10 实用的 let 方法以及客制化错误讯息!

该文章同步发布於:我的部落格 改变数值的时候 昨天提到变动性的问题是什麽呢? 我们到现在的测试都是...

【Day 28】 服务器监控 on AWS

那麽在先前实作中,我们业已将 WordPress 网站建筑在 AWS 环境中(可以详【Day 05】...

加上random与time模组,限制次数与时间的管理(2)

制造一个停止条件 我会在这边设置新变数 mat = 0 但是我也必须要有一个可以写入的函式 asyn...