但是以上的內容我都是帶過,不會花太時間解釋
我們的注意力會放在單元測試上。

這個是目前View的畫面,目前還沒有套用任何邏輯。
目標是讓輸入匡輸入5個字元,就算是符合規範。
帳號與密碼都符合規範,Login in 的按鈕才可以按。
這是目前的view Controller,什麼東西都沒有。
class LoginPageViewController:UIViewController{}
先在 LoadView 讀取客製化的view 。
class LoginPageViewController:UIViewController{
    var loginPageView:LoginPageView!
    
//MARK: - LoadView()
    override func loadView() {
        loginPageView = LoginPageView()
        self.view = loginPageView
    }
}
很好,已經讀到畫面了。
但是還是沒辦法有邏輯上的連動
我把binding的過程分開,當然你可以寫在一起。
為了refacter方便我會分開寫。
//MARK: - DataBinding()
    func dataBinding(){
        //observable
        //vaild
        //bind
    }
}
這裡我創建了兩個推送事件序列,這兩個推送的物件是 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
    }
這裡有三道業務邏輯。
//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)
    }

以上我們已經完成了資料綁定,已經可以做互動了。
我們從一個測試類別開始
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的物件。
我們是在 LoadView() 實例化物件的。所以我們讓 sut 執行 LoadView()
    override func setUp() {
        super.setUp()
        sut = LoginPageViewController()
        sut.loadView()
    }
command + u 再測試一次。
測試成功了
我們從一個空白的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已經算是完成了,我們來對他做測試吧。
配置好業務邏輯需要的基本設定
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
測試成功
MVC在單元測試時,必須要實例化view(MVC變著不納入討論),這使單元測試偏離了原生單元測試的設計。因為單元測試就應該測試業務邏輯,他不關心UI上面的變化。
MVVM 與 MVC 的差異就是把業務邏輯抽離出來,這讓單元測試上有很大的幫助,我可以更專注在業務邏輯上的測試,而不用擔心View的生命週期。