如何在ReactiveCocoa中寫單元測試
現在很多人在開發iOS時都使用 ReactiveCocoa ,它是一個函數式和響應式編程的框架,使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞消息和解決對象之間狀態與狀態的依賴過多問題。但很多時候使用它之后,如何編寫 單元測試 來驗證程序是否正確呢?下面首先了解MVVM架構,然后通過一個 例子 來講述我如何在RAC(ReactiveCocoa簡稱)中使用 Kiwi 來編寫單元測試。
MVVM架構
在MVVM架構中,通常都將view和view controller看做一個整體。相對于之前MVC架構中view controller執行很多在view和model之間數據映射和交互的工作,現在將它交給view model去做。
至于選擇哪種機制來更新view model或view是沒有強制的,但通常我們都選擇 ReactiveCocoa 。ReactiveCocoa會監聽model的改變然后將這些改變映射到view model的屬性中,并且可以執行一些業務邏輯。
舉個例子來說,有一個model包含一個dateAdded的屬性,我想監聽它的變化然后更新view model的dateAdded屬性。但model的dateAdded屬性的數據類型是NSDate,而view model的數據類型是NSString,所以在view model的init方法中進行數據綁定,但需要數據類型轉換。示例代碼如下:
RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){
return [[ViewModel dateFormatter] stringFromDate:date];
}];
ViewModel調用dateFormatter進行數據轉換,且方法dateFormatter可以復用到其他地方。然后view controller監聽view model的dateAdded屬性且綁定到label的text屬性。
RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);
現在我們抽象出日期轉換到字符串的邏輯到view model,使得代碼可以 測試 和 復用 ,并且幫view controller 瘦身 。
登錄情景
如圖所示,這是一個簡單的 登錄界面 :有用戶名和密碼的兩個輸入框,一個登錄按鈕。用戶輸入完用戶名和密碼后,點擊登錄按鈕后,成功登錄。但這里有 限制條件 :用戶名必須滿足郵件的格式和密碼長度必須在6位以上。當同時滿足這兩個條件后才能點擊按鈕,否則按鈕是不可點擊的。
首先我們先畫界面,我定義一個 LoginView ,將畫登錄界面的責任都交給它。然后在 LoginViewController 中的 viewDidLoad 方法調用 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);
}];
}
接下來我們要思考UI如何交互和如何設計和實現哪些類來處理。由于用戶名和密碼要同時滿足驗證格式時才能點擊登錄按鈕,所以需要時刻監聽 usernameTextField 和 passwordTextField 的text屬性,對于處理UI交互、數據校驗以及轉換都交給MVVM架構中 ViewModel 來處理。于是定義一個 LoginViewModel ,并繼承 RVMViewModel ,這個 RVMViewModel 有個 active 屬性來表示viewModel是否處于活躍狀態,當active是YES時,更新或顯示UI。當active是NO時,不更新或隱藏UI。
@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 來初始化它。
@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;
}
最后調用 bindData 方法進行 數據綁定
- (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已經綁定數據,那么usernameTextField.text和passwordTextField.text的數據變動的話,一定會引起loginViewModel.username和loginViewModel.password的改變。那么 測試用例 可以這樣設計:
數據綁定 Test Case
用kiwi編寫測試如下:
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 一定要調用 view 方法來加載controller的view,否則不會調用 viewDidLoad 方法。
-
usernameTextField和passwordTextField一定要調用 sendActionsForControlEvents 方法來通知UI已經更新。
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged]; [rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];
一開始時,我并沒有調用 sendActionsForControlEvents 方法導致 loginViewModel.username 和 loginViewModel.password 屬性并沒有更新。當時我開始思考,是不是還需要其他條件還能觸發它更新呢?由于我使用 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]; }
從源代碼可以知道,只有觸發 UIControlEventEditingChanged 或 UIControlEventEditingDidBegin 事件時才能創建RACSignal對象。
業務邏輯測試
由于這里需要驗證用戶名和密碼,復用性高,我不將處理邏輯放在viewModel中,而是定義一個 DataValidation 來處理。這里的用戶名是郵箱格式,而密碼要求長度大于等于6即可,方法如下:
@interface DataValidation : NSObject+ (BOOL)isValidEmail:(NSString *)data;
+ (BOOL)isValidPassword:(NSString *)password;@end
測試用例設計如下:
然后使用kiwi編寫測試如下:
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];
});
});
......省略兩個測試用例
});
ViewModel層測試
前面已經完成了數據綁定和數據校驗邏輯,接下來思考使用哪個類處理用戶名和密碼是否有效才能點擊和點擊按鈕后,如何調用網絡層在來匹配用戶名和密碼,RAC提供一個 RACCommand 類。 LoginViewModel 定義一個屬性 loginCommand ,并在 實現文件 中使用 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;
}
上面有一個重要方法 isValidUsernameAndPasswordSignal 來監聽和驗證用戶名和密碼:
- (RACSignal *)isValidUsernameAndPasswordSignal
{ return [RACSignal combineLatest:@[RACObserve(self, username), RACObserve(self, password)] reduce:^(NSString *username, NSString *password) { return @([DataValidation isValidEmail:username] && [DataValidation isValidPassword:password]);
}];
}
由于上面的方法 isValidUsernameAndPasswordSignal 已經監聽 LoginViewModel 的username和password,當username和password其中一個改變時, DataValidation 類都會調用 isValidEmail 和 isValidPassword 來數據驗證,并將結果包裹成 RACSignal 對象返回。
測試用例設計如下:
然后使用kiwi編寫測試如下:
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];
});
});
......省略兩個測試用例
});
以上測試用例很簡單,設置viewModel的username和password,然后調用 isValidUsernameAndPasswordSignal 返回RACSignal對象,使用 subscribeNext 獲取它的值,最后驗證。
網絡層測試
最后處理點擊登錄按鈕訪問服務器來驗證用戶名和密碼。我定義一個 LoginClient 類來處理:
@interface LoginClient : NSObject+ (RACSignal *)loginWithUsername:(NSString *)username password:(NSString *)password;@end
只要輸入username和password兩個參數,就能返回是否驗證成功的結果被包裹在 RACSignal 對象中。
由于這里我是使用 moco 模擬服務,所以只設計一個成功的測試用例:
然后使用kiwi編寫測試如下:
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的一個重要方法 asynchronousFirstOrDefault 來測試異步網絡訪問的。
抓取網絡數據并顯示情景
如圖所示,輸入正確的用戶名和密碼后,跳轉到一個食物列表頁面,它從服務端抓取圖片、價格和已售份數后以列表的方式顯示。
網絡層測試
首先考慮如何設計和實現API,然后再考慮如何測試。因為它需要從服務端抓取數據,需要設計一個訪問食物列表數據的類 FoodListClient ,設計如下:
@interface FoodListClient : NSObject+ (RACSignal *)fetchFoodList;@end
FoodListClient 實現如下:
@implementation FoodListClient
+ (RACSignal *)fetchFoodList{ return [[[AFHTTPSessionManager manager] rac_GET:[URLHelper URLWithResourcePath:@"/v1/foodlist"] parameters:nil] replayLazily];
}@end
fetchFoodList 方法主要從服務端抓取數據后,返回一個JSON格式的數組。因此想測試這個API,只需要使用RAC的 asynchronousFirstOrDefault 方法返回 RACTuple 對象,獲取第一個值,測試返回數組不為空即可。使用kiwi編寫測試如下:
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層測試
抓取完數據后,它的數據格式一般都是JSON格式,需要轉化為Model方便訪問和修改,通常我都使用 Mantle 來實現。我定義一個 FoodModel 類:
@interface FoodModel : MTLModel /*
@brief 食物圖片URL
*/@property (copy, nonatomic) NSString *foodImageURL;/*
@brief 食物價格
*/@property (copy, nonatomic) NSString *foodPrice;/*
@brief 銷量
*/@property (copy, nonatomic) NSString *saleNumber;@end
那么如何測試它是否轉化成功呢?首先基于上一個網絡層測試獲取返回JSON格式的食物列表數據,然后調用 MTLJSONAdapter 類的 modelsOfClass: fromJSONArray: error: 方法來轉化成 FoodModel 的數組。接下來斷言 數組不能為空 和 數組的第一個元素是 FoodModel 類 。
使用kiwi編寫測試如下:
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抓取數據
完成抓取網絡數據和轉化JSON數據為Model后,我使用 FoodViewModel 來 抓取網絡數據 和完成 數據映射 ,設計與實現如下:
@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加載數據
最后 FoodListViewController 負責構建view hierarchy和加載數據:
#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];
}];
}
總結
編寫單元測試是程序員的一項 基本技能 ,如果能夠設計好的測試用例并編寫測試驗證結果,不僅保證代碼的質量,而且有利于以后重構加一層保護層。一旦修改了代碼之后,如果運行單元測試,并沒有通過的話,說明你在重構過程中引入新的bug。如果通過了單元測試,說明并沒有引入新的bug。
來自:http://www.cocoachina.com/ios/20161103/17934.html