iT邦幫忙

0

從單元測試探討 MVC to MVVM 的差異

  • 分享至 

  • xImage
  •  

從單元測試探討 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有哪些方法?

圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言