現(xiàn)在很多人在開發(fā)iOS時都使用ReactiveCocoa,它是一個函數(shù)式和響應(yīng)式編程的框架,使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞消息和解決對象之間狀態(tài)與狀態(tài)的依賴過多問題。但很多時候使用它之后,如何編寫單元測試來驗證程序是否正確呢?下面首先了解MVVM架構(gòu),然后通過一個例子來講述我如何在RAC(ReactiveCocoa簡稱)中使用Kiwi來編寫單元測試。

在MVVM架構(gòu)中,通常都將view和view controller看做一個整體。相對于之前MVC架構(gòu)中view controller執(zhí)行很多在view和model之間數(shù)據(jù)映射和交互的工作,現(xiàn)在將它交給view model去做。
至于選擇哪種機制來更新view model或view是沒有強制的,但通常我們都選擇ReactiveCocoa。ReactiveCocoa會監(jiān)聽model的改變?nèi)缓髮⑦@些改變映射到view model的屬性中,并且可以執(zhí)行一些業(yè)務(wù)邏輯。
舉個例子來說,有一個model包含一個dateAdded的屬性,我想監(jiān)聽它的變化然后更新view model的dateAdded屬性。但model的dateAdded屬性的數(shù)據(jù)類型是NSDate,而view model的數(shù)據(jù)類型是NSString,所以在view model的init方法中進行數(shù)據(jù)綁定,但需要數(shù)據(jù)類型轉(zhuǎn)換。示例代碼如下:
[cpp] view plaincopy
RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){
return [[ViewModel dateFormatter] stringFromDate:date];
}];
ViewModel調(diào)用dateFormatter進行數(shù)據(jù)轉(zhuǎn)換,且方法dateFormatter可以復(fù)用到其他地方。然后view controller監(jiān)聽view model的dateAdded屬性且綁定到label的text屬性。
[cpp] view plaincopy
RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);
現(xiàn)在我們抽象出日期轉(zhuǎn)換到字符串的邏輯到view model,使得代碼可以測試和復(fù)用,并且?guī)蛌iew controller瘦身。

如圖所示,這是一個簡單的登錄界面:有用戶名和密碼的兩個輸入框,一個登錄按鈕。用戶輸入完用戶名和密碼后,點擊登錄按鈕后,成功登錄。但這里有限制條件:用戶名必須滿足郵件的格式和密碼長度必須在6位以上。當同時滿足這兩個條件后才能點擊按鈕,否則按鈕是不可點擊的。大家可以從Github中下載實例代碼。
首先我們先畫界面,我定義一個LoginView,將畫登錄界面的責任都交給它。然后在LoginViewController中的viewDidLoad方法調(diào)用buildViewHierarchy加載它。
[cpp] view plaincopy
#pragma mark - Lifecycle
- (void)viewDidLoad {
[super viewDidLoad];
// build view hierarchy
[self buildViewHierarchy];
// bind data
[self bindData];
// handle events
[self handleEvents];
}
- (void)buildViewHierarchy
{
[self.view addSubview:self.rootView];
[self.rootView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.view);
}];
}
接下來我們要思考UI如何交互和如何設(shè)計和實現(xiàn)哪些類來處理。由于用戶名和密碼要同時滿足驗證格式時才能點擊登錄按鈕,所以需要時刻監(jiān)聽usernameTextField和passwordTextField的text屬性,對于處理UI交互、數(shù)據(jù)校驗以及轉(zhuǎn)換都交給MVVM架構(gòu)中ViewModel來處理。于是定義一個LoginViewModel,并繼承RVMViewModel,這個RVMViewModel有個active屬性來表示viewModel是否處于活躍狀態(tài),當active是YES時,更新或顯示UI。當active是NO時,不更新或隱藏UI。
[cpp] view plaincopy
@interface LoginViewModel : RVMViewModel
#pragma mark - UI state
/*
@brief 用戶名
*/
@property (copy, nonatomic) NSString *username;
/*
@brief 密碼
*/
@property (copy, nonatomic) NSString *password;
#pragma mark - Handle events
/*
@brief 處理用戶民和密碼是否有效才能點擊按鈕以及登陸事件
*/
@property (nonatomic, strong) RACCommand *loginCommand;
#pragma mark - Methods
- (RACSignal *)isValidUsernameAndPasswordSignal;
@end
上面還有一個loginCommand屬性和isValidUsernameAndPasswordSignal方法等下會詳細介紹。定義LoginViewModel類后,在LoginViewController以組合和委托的方式來使用LoginViewModel并使用Lazy Initialization來初始化它。
[cpp] view plaincopy
@interface LoginViewController ()
#pragma mark - View model
@property (strong, nonatomic) LoginViewModel *loginViewModel;
@end
@implementation LoginViewController
#pragma mark - Custom Accessors
- (LoginViewModel *)loginViewModel
{
if (!_loginViewModel) {
_loginViewModel = [LoginViewModel new];
}
return _loginViewModel;
}
后調(diào)用bindData方法進行數(shù)據(jù)綁定:
[cpp] view plaincopy
- (void)bindData
{
RAC(self.loginViewModel, username) = self.rootView.usernameTextField.rac_textSignal;
RAC(self.loginViewModel, password) = self.rootView.passwordTextField.rac_textSignal;
}
如果usernameTextField.text、passwordTextField.text與loginViewModel.username、loginViewModel.password已經(jīng)綁定數(shù)據(jù),那么usernameTextField.text和passwordTextField.text的數(shù)據(jù)變動的話,一定會引起loginViewModel.username和loginViewModel.password的改變。那么測試用例可以這樣設(shè)計:

圖:數(shù)據(jù)綁定Test Case
用kiwi編寫測試如下:
[cpp] view plaincopy
SPEC_BEGIN(LoginViewControllerSpec)
describe(@"LoginViewController", ^{
__block LoginViewController *controller = nil;
beforeEach(^{
controller = [LoginViewController new];
[controller view];
});
afterEach(^{
controller = nil;
});
describe(@"Root View", ^{
__block LoginView *rootView = nil;
beforeEach(^{
rootView = controller.rootView;
});
context(@"when view did load", ^{
it(@"should bind data", ^{
rootView.usernameTextField.text = @"samlau";
rootView.passwordTextField.text = @"freedom";
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[[controller.loginViewModel.username should] equal:rootView.usernameTextField.text];
[[controller.loginViewModel.password should] equal:rootView.passwordTextField.text];
});
});
});
});
SPEC_END
這個測試中有兩點需要重點解釋:
初始化完controller之后,controller一定要調(diào)用view方法來加載controller的view,否則不會調(diào)用viewDidLoad方法。
如果有些朋友對controller如何管理view生命周期不了解,可以閱讀View Controller Programming Guide for iOS文檔中的A View Controller Instantiates Its View Hierarchy When Its View is Accessed章節(jié)。

圖:Loading a view into memory from Apple Document
usernameTextField和passwordTextField一定要調(diào)用sendActionsForControlEvents方法來通知UI已經(jīng)更新。
[cpp] view plaincopy
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];
一開始時,我并沒有調(diào)用sendActionsForControlEvents方法導致loginViewModel.username和loginViewModel.password屬性并沒有更新。當時我開始思考,是不是還需要其他條件還能觸發(fā)它更新呢?由于我使用UITextField的rac_textSignal屬性,于是我就查看它的源代碼:
[cpp] view plaincopy
- (RACSignal *)rac_textSignal {
@weakify(self);
return [[[[[RACSignal
defer:^{
@strongify(self);
return [RACSignal return:self];
}]
concat:[self rac_signalForControlEvents:UIControlEventEditingChanged | UIControlEventEditingDidBegin]]
map:^(UITextField *x) {
return x.text;
}]
takeUntil:self.rac_willDeallocSignal]
setNameWithFormat:@"%@ -rac_textSignal", self.rac_description];
}
從源代碼可以知道,只有觸發(fā)UIControlEventEditingChanged或UIControlEventEditingDidBegin事件時才能創(chuàng)建RACSignal對象。
本站文章版權(quán)歸原作者及原出處所有 。內(nèi)容為作者個人觀點, 并不代表本站贊同其觀點和對其真實性負責,本站只提供參考并不構(gòu)成任何投資及應(yīng)用建議。本站是一個個人學習交流的平臺,網(wǎng)站上部分文章為轉(zhuǎn)載,并不用于任何商業(yè)目的,我們已經(jīng)盡可能的對作者和來源進行了通告,但是能力有限或疏忽,造成漏登,請及時聯(lián)系我們,我們將根據(jù)著作權(quán)人的要求,立即更正或者刪除有關(guān)內(nèi)容。本站擁有對此聲明的最終解釋權(quán)。