現(xiàn)在很多人在開(kāi)發(fā)iOS時(shí)都使用ReactiveCocoa援所,它是一個(gè)函數(shù)式和響應(yīng)式編程的框架,使用Signal來(lái)代替KVO臂痕、Notification匾南、Delegate和Target-Action等傳遞消息和解決對(duì)象之間狀態(tài)與狀態(tài)的依賴(lài)過(guò)多問(wèn)題。但很多時(shí)候使用它之后馏锡,如何編寫(xiě)單元測(cè)試來(lái)驗(yàn)證程序是否正確呢雷蹂?下面首先了解MVVM架構(gòu),然后通過(guò)一個(gè)例子來(lái)講述我如何在RAC(ReactiveCocoa簡(jiǎn)稱(chēng))中使用Kiwi來(lái)編寫(xiě)單元測(cè)試杯道。
MVVM架構(gòu)
在MVVM架構(gòu)中匪煌,通常都將view和view controller看做一個(gè)整體。相對(duì)于之前MVC架構(gòu)中view controller執(zhí)行很多在view和model之間數(shù)據(jù)映射和交互的工作,現(xiàn)在將它交給view model去做萎庭。
至于選擇哪種機(jī)制來(lái)更新view model或view是沒(méi)有強(qiáng)制的霜医,但通常我們都選擇ReactiveCocoa。ReactiveCocoa會(huì)監(jiān)聽(tīng)model的改變?nèi)缓髮⑦@些改變映射到view model的屬性中驳规,并且可以執(zhí)行一些業(yè)務(wù)邏輯肴敛。
舉個(gè)例子來(lái)說(shuō),有一個(gè)model包含一個(gè)dateAdded的屬性吗购,我想監(jiān)聽(tīng)它的變化然后更新view model的dateAdded屬性医男。但model的dateAdded屬性的數(shù)據(jù)類(lèi)型是NSDate,而view model的數(shù)據(jù)類(lèi)型是NSString捻勉,所以在view model的init方法中進(jìn)行數(shù)據(jù)綁定镀梭,但需要數(shù)據(jù)類(lèi)型轉(zhuǎn)換。示例代碼如下:
RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){
return [[ViewModel dateFormatter] stringFromDate:date];
}];
ViewModel調(diào)用dateFormatter進(jìn)行數(shù)據(jù)轉(zhuǎn)換踱启,且方法dateFormatter可以復(fù)用到其他地方报账。然后view controller監(jiān)聽(tīng)view model的dateAdded屬性且綁定到label的text屬性。
RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);
現(xiàn)在我們抽象出日期轉(zhuǎn)換到字符串的邏輯到view model禽捆,使得代碼可以測(cè)試和復(fù)用笙什,并且?guī)蛌iew controller瘦身。
登錄情景
如圖所示胚想,這是一個(gè)簡(jiǎn)單的登錄界面:有用戶(hù)名和密碼的兩個(gè)輸入框琐凭,一個(gè)登錄按鈕。用戶(hù)輸入完用戶(hù)名和密碼后浊服,點(diǎn)擊登錄按鈕后统屈,成功登錄。但這里有限制條件:用戶(hù)名必須滿(mǎn)足郵件的格式和密碼長(zhǎng)度必須在6位以上牙躺。當(dāng)同時(shí)滿(mǎn)足這兩個(gè)條件后才能點(diǎn)擊按鈕愁憔,否則按鈕是不可點(diǎn)擊的。大家可以從github中下載實(shí)例代碼孽拷。
首先我們先畫(huà)界面吨掌,我定義一個(gè)LoginView
,將畫(huà)登錄界面的責(zé)任都交給它脓恕。然后在LoginViewController
中的viewDidLoad
方法調(diào)用buildViewHierarchy
加載它
#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);
}];
}
接下來(lái)我們要思考UI如何交互和如何設(shè)計(jì)和實(shí)現(xiàn)哪些類(lèi)來(lái)處理膜宋。由于用戶(hù)名和密碼要同時(shí)滿(mǎn)足驗(yàn)證格式時(shí)才能點(diǎn)擊登錄按鈕,所以需要時(shí)刻監(jiān)聽(tīng)usernameTextField
和passwordTextField
的text屬性炼幔,對(duì)于處理UI交互秋茫、數(shù)據(jù)校驗(yàn)以及轉(zhuǎn)換都交給MVVM架構(gòu)中ViewModel
來(lái)處理。于是定義一個(gè)LoginViewModel
,并繼承RVMViewModel
乃秀,這個(gè)RVMViewModel
有個(gè)active
屬性來(lái)表示viewModel是否處于活躍狀態(tài)肛著,當(dāng)active是YES時(shí)圆兵,更新或顯示UI。當(dāng)active是NO時(shí)枢贿,不更新或隱藏UI殉农。
@interface LoginViewModel : RVMViewModel
#pragma mark - UI state
/*
@brief 用戶(hù)名
*/
@property (copy, nonatomic) NSString *username;
/*
@brief 密碼
*/
@property (copy, nonatomic) NSString *password;
#pragma mark - Handle events
/*
@brief 處理用戶(hù)民和密碼是否有效才能點(diǎn)擊按鈕以及登陸事件
*/
@property (nonatomic, strong) RACCommand *loginCommand;
#pragma mark - Methods
- (RACSignal *)isValidUsernameAndPasswordSignal;
@end
上面還有一個(gè)loginCommand
屬性和isValidUsernameAndPasswordSignal
方法等下會(huì)詳細(xì)介紹。定義LoginViewModel
類(lèi)后局荚,在LoginViewController
以組合和委托的方式來(lái)使用LoginViewModel
并使用Lazy Initialization來(lái)初始化它统抬。
@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
方法進(jìn)行數(shù)據(jù)綁定
- (void)bindData
{
RAC(self.loginViewModel, username) = self.rootView.usernameTextField.rac_textSignal;
RAC(self.loginViewModel, password) = self.rootView.passwordTextField.rac_textSignal;
}
數(shù)據(jù)綁定測(cè)試
如果usernameTextField.text、passwordTextField.text與loginViewModel.username危队、loginViewModel.password已經(jīng)綁定數(shù)據(jù),那么usernameTextField.text和passwordTextField.text的數(shù)據(jù)變動(dòng)的話(huà)钙畔,一定會(huì)引起loginViewModel.username和loginViewModel.password的改變茫陆。那么測(cè)試用例可以這樣設(shè)計(jì):
用kiwi編寫(xiě)測(cè)試如下:
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
這個(gè)測(cè)試中有兩點(diǎn)需要重點(diǎn)解釋?zhuān)?/p>
- 初始化完controller之后,
controller
一定要調(diào)用view
方法來(lái)加載controller的view擎析,否則不會(huì)調(diào)用viewDidLoad
方法簿盅。
如果有些朋友對(duì)controller如何管理view生命周期不了解,可以閱讀View Controller Programming Guide for iOS文檔中的A View Controller Instantiates Its View Hierarchy When Its View is Accessed章節(jié)
- usernameTextField和passwordTextField一定要調(diào)用
sendActionsForControlEvents
方法來(lái)通知UI已經(jīng)更新揍魂。
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];
一開(kāi)始時(shí)桨醋,我并沒(méi)有調(diào)用sendActionsForControlEvents
方法導(dǎo)致loginViewModel.username
和loginViewModel.password
屬性并沒(méi)有更新。當(dāng)時(shí)我開(kāi)始思考现斋,是不是還需要其他條件還能觸發(fā)它更新呢喜最?由于我使用UITextField
的rac_textSignal
屬性,于是我就查看它的源代碼:
- (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
事件時(shí)才能創(chuàng)建RACSignal對(duì)象瞬内。
業(yè)務(wù)邏輯測(cè)試
由于這里需要驗(yàn)證用戶(hù)名和密碼,復(fù)用性高限书,我不將處理邏輯放在viewModel中虫蝶,而是定義一個(gè)DataValidation
來(lái)處理。這里的用戶(hù)名是郵箱格式倦西,而密碼要求長(zhǎng)度大于等于6即可能真,方法如下:
@interface DataValidation : NSObject
+ (BOOL)isValidEmail:(NSString *)data;
+ (BOOL)isValidPassword:(NSString *)password;
@end
測(cè)試用例設(shè)計(jì)如下:
然后使用kiwi編寫(xiě)測(cè)試如下:
SPEC_BEGIN(DataValidationSpec)
describe(@"DataValidation", ^{
context(@"when email is samlau@163.com", ^{
it(@"should return YES", ^{
BOOL result = [DataValidation isValidEmail:@"samlau@163.com"];
[[theValue(result) should] beYes];
});
});
context(@"when email is samlau163.com", ^{
it(@"should return YES", ^{
BOOL result = [DataValidation isValidEmail:@"samlau163.com"];
[[theValue(result) should] beNo];
});
});
......省略?xún)蓚€(gè)測(cè)試用例
});
ViewModel層測(cè)試
前面已經(jīng)完成了數(shù)據(jù)綁定和數(shù)據(jù)校驗(yàn)邏輯,接下來(lái)思考使用哪個(gè)類(lèi)處理用戶(hù)名和密碼是否有效才能點(diǎn)擊和點(diǎn)擊按鈕后扰柠,如何調(diào)用網(wǎng)絡(luò)層在來(lái)匹配用戶(hù)名和密碼粉铐,RAC提供一個(gè)RACCommand
類(lèi)。LoginViewModel
定義一個(gè)屬性loginCommand
耻矮,并在實(shí)現(xiàn)文件中使用Lazy Initialization
初始化:
- (RACCommand *)loginCommand
{
if (!_loginCommand) {
_loginCommand = [[RACCommand alloc] initWithEnabled:[self isValidUsernameAndPasswordSignal] signalBlock:^RACSignal *(id input) {
return [LoginClient loginWithUsername:self.username password:self.password];
}];
}
return _loginCommand;
}
上面有一個(gè)重要方法isValidUsernameAndPasswordSignal
來(lái)監(jiān)聽(tīng)和驗(yàn)證用戶(hù)名和密碼:
- (RACSignal *)isValidUsernameAndPasswordSignal
{
return [RACSignal combineLatest:@[RACObserve(self, username), RACObserve(self, password)] reduce:^(NSString *username, NSString *password) {
return @([DataValidation isValidEmail:username] && [DataValidation isValidPassword:password]);
}];
}
由于上面的方法isValidUsernameAndPasswordSignal
已經(jīng)監(jiān)聽(tīng)LoginViewModel
的username和password秦躯,當(dāng)username和password其中一個(gè)改變時(shí),DataValidation
類(lèi)都會(huì)調(diào)用isValidEmail
和isValidPassword
來(lái)數(shù)據(jù)驗(yàn)證裆装,并將結(jié)果包裹成RACSignal
對(duì)象返回踱承。
測(cè)試用例設(shè)計(jì)如下:
然后使用kiwi編寫(xiě)測(cè)試如下:
describe(@"LoginViewModel", ^{
__block LoginViewModel* viewModel = nil;
beforeEach(^{
viewModel = [LoginViewModel new];
});
afterEach(^{
viewModel = nil;
});
context(@"when username is samlau@163.com and password is freedom", ^{
__block BOOL result = NO;
it(@"should return signal that value is YES", ^{
viewModel.username = @"samlau@163.com";
viewModel.password = @"freedom";
[[viewModel isValidUsernameAndPasswordSignal] subscribeNext:^(id x) {
result = [x boolValue];
}];
[[theValue(result) should] beYes];
});
});
......省略?xún)蓚€(gè)測(cè)試用例
});
以上測(cè)試用例很簡(jiǎn)單倡缠,設(shè)置viewModel的username和password,然后調(diào)用isValidUsernameAndPasswordSignal
返回RACSignal對(duì)象茎活,使用subscribeNext
獲取它的值昙沦,最后驗(yàn)證。
網(wǎng)絡(luò)層測(cè)試
最后處理點(diǎn)擊登錄按鈕訪(fǎng)問(wèn)服務(wù)器來(lái)驗(yàn)證用戶(hù)名和密碼载荔。我定義一個(gè)LoginClient
類(lèi)來(lái)處理:
@interface LoginClient : NSObject
+ (RACSignal *)loginWithUsername:(NSString *)username password:(NSString *)password;
@end
只要輸入username和password兩個(gè)參數(shù)盾饮,就能返回是否驗(yàn)證成功的結(jié)果被包裹在RACSignal
對(duì)象中。
由于這里我是使用moco模擬服務(wù)懒熙,所以只設(shè)計(jì)一個(gè)成功的測(cè)試用例:
然后使用kiwi編寫(xiě)測(cè)試如下:
describe(@"LoginClient", ^{
context(@"when username is samlau@163.com and password is samlau", ^{
__block BOOL success = NO;
__block NSError *error = nil;
it(@"should login successfully", ^{
RACTuple *tuple = [[LoginClient loginWithUsername:@"samlau@163.com" password:@"samlau"] asynchronousFirstOrDefault:nil success:&success error:&error];
NSDictionary *result = tuple.first;
[[theValue(success) should] beYes];
[[error should] beNil];
[[result[@"result"] should] equal:@"success"];
});
});
});
里面使用RAC的一個(gè)重要方法asynchronousFirstOrDefault
來(lái)測(cè)試異步網(wǎng)絡(luò)訪(fǎng)問(wèn)的丘损。詳情可參考Test with Reactivecocoa文章。
抓取網(wǎng)絡(luò)數(shù)據(jù)并顯示情景
如圖所示工扎,輸入正確的用戶(hù)名和密碼后徘钥,跳轉(zhuǎn)到一個(gè)食物列表頁(yè)面,它從服務(wù)端抓取圖片肢娘、價(jià)格和已售份數(shù)后以列表的方式顯示呈础。
網(wǎng)絡(luò)層測(cè)試
首先考慮如何設(shè)計(jì)和實(shí)現(xiàn)API,然后再考慮如何測(cè)試橱健。因?yàn)樗枰獜姆?wù)端抓取數(shù)據(jù)而钞,需要設(shè)計(jì)一個(gè)訪(fǎng)問(wèn)食物列表數(shù)據(jù)的類(lèi)FoodListClient
,設(shè)計(jì)如下:
@interface FoodListClient : NSObject
+ (RACSignal *)fetchFoodList;
@end
FoodListClient
實(shí)現(xiàn)如下:
@implementation FoodListClient
+ (RACSignal *)fetchFoodList
{
return [[[AFHTTPSessionManager manager] rac_GET:[URLHelper URLWithResourcePath:@"/v1/foodlist"] parameters:nil] replayLazily];
}
@end
fetchFoodList
方法主要從服務(wù)端抓取數(shù)據(jù)后拘荡,返回一個(gè)JSON格式的數(shù)組臼节。因此想測(cè)試這個(gè)API,只需要使用RAC的asynchronousFirstOrDefault
方法返回RACTuple
對(duì)象珊皿,獲取第一個(gè)值官疲,測(cè)試返回?cái)?shù)組不為空即可。使用kiwi編寫(xiě)測(cè)試如下:
describe(@"FoodListClient", ^{
context(@"when fetch food list ", ^{
__block BOOL successful = NO;
__block NSError *error = nil;
it(@"should receive data", ^{
RACSignal *result = [FoodListClient fetchFoodList];
RACTuple *tuple = [result asynchronousFirstOrDefault:nil success:&successful error:&error];
NSArray *foodList = tuple.first;
[[theValue(successful) should] beYes];
[[error should] beNil];
[[foodList shouldNot] beEmpty];
});
});
});
Model層測(cè)試
抓取完數(shù)據(jù)后亮隙,它的數(shù)據(jù)格式一般都是JSON格式途凫,需要轉(zhuǎn)化為Model方便訪(fǎng)問(wèn)和修改,通常我都使用Mantle來(lái)實(shí)現(xiàn)溢吻。我定義一個(gè)FoodModel
類(lèi):
@interface FoodModel : MTLModel <MTLJSONSerializing>
/*
@brief 食物圖片URL
*/
@property (copy, nonatomic) NSString *foodImageURL;
/*
@brief 食物價(jià)格
*/
@property (copy, nonatomic) NSString *foodPrice;
/*
@brief 銷(xiāo)量
*/
@property (copy, nonatomic) NSString *saleNumber;
@end
那么如何測(cè)試它是否轉(zhuǎn)化成功呢维费?首先基于上一個(gè)網(wǎng)絡(luò)層測(cè)試獲取返回JSON格式的食物列表數(shù)據(jù),然后調(diào)用MTLJSONAdapter
類(lèi)的modelsOfClass: fromJSONArray: error:
方法來(lái)轉(zhuǎn)化成FoodModel
的數(shù)組促王。接下來(lái)斷言數(shù)組不能為空和數(shù)組的第一個(gè)元素是FoodModel
類(lèi)犀盟。
使用kiwi編寫(xiě)測(cè)試如下:
describe(@"FoodModel", ^{
context(@"when JSON data convert to FoodModel", ^{
__block BOOL successful = NO;
__block NSError *error = nil;
it(@"should return FoodModel array", ^{
// get data from network
RACSignal *result = [FoodListClient fetchFoodList];
RACTuple *tuple = [result asynchronousFirstOrDefault:nil success:&successful error:&error];
NSArray *foodList = tuple.first;
// assert that foodList can't be empty
[[theValue(successful) should] beYes];
[[error should] beNil];
[[foodList shouldNot] beEmpty];
// assert that return FoolModel array
NSArray *foodModelList = [MTLJSONAdapter modelsOfClass:[FoodModel class] fromJSONArray:foodList error:nil];
[[foodModelList shouldNot] beEmpty];
[[foodModelList[0] should] beKindOfClass:[FoodModel class]];
});
});
});
ViewModel抓取數(shù)據(jù)
完成抓取網(wǎng)絡(luò)數(shù)據(jù)和轉(zhuǎn)化JSON數(shù)據(jù)為Model后,我使用FoodViewModel
來(lái)抓取網(wǎng)絡(luò)數(shù)據(jù)和完成數(shù)據(jù)映射蝇狼,設(shè)計(jì)與實(shí)現(xiàn)如下:
@interface FoodViewModel : RVMViewModel
/*
@brief FoodModel列表
*/
@property (strong, nonatomic, readonly) NSArray *foodModelList;
@end
@implementation FoodViewModel
- (instancetype)init
{
self = [super init];
if (!self) {
return nil;
}
RAC(self, foodModelList) = [[FoodListClient fetchFoodList] map:^id(RACTuple * tuple) {
return [MTLJSONAdapter modelsOfClass:[FoodModel class] fromJSONArray:tuple.first error:nil];
}];
return self;
}
@end
Controller加載數(shù)據(jù)
最后FoodListViewController
負(fù)責(zé)構(gòu)建view hierarchy和加載數(shù)據(jù):
#pragma mark - Lifecycle
- (void)viewDidLoad
{
[super viewDidLoad];
// setup title name and background color
self.title = @"食物列表";
self.view.backgroundColor = [UIColor whiteColor];
// build view hierarchy
[self buildViewHierarchy];
// when finish fetching data and reload table view
[RACObserve(self.foodViewModel, foodModelList) subscribeNext:^(NSArray* items) {
self.foodListDataSource.items = items;
[self.tableView reloadData];
}];
}
總結(jié)
編寫(xiě)單元測(cè)試是程序員的一項(xiàng)基本技能阅畴,如果能夠設(shè)計(jì)好的測(cè)試用例并編寫(xiě)測(cè)試驗(yàn)證結(jié)果,不僅保證代碼的質(zhì)量迅耘,而且有利于以后重構(gòu)加一層保護(hù)層贱枣。一旦修改了代碼之后监署,如果運(yùn)行單元測(cè)試,并沒(méi)有通過(guò)的話(huà)纽哥,說(shuō)明你在重構(gòu)過(guò)程中引入新的bug钠乏。如果通過(guò)了單元測(cè)試,說(shuō)明并沒(méi)有引入新的bug春塌。