現在很多人在開發iOS時都使用ReactiveCocoa,它是一個函數式和響應式編程的框架,使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞消息和解決對象之間狀態與狀態的依賴過多問題。但很多時候使用它之後,如何編寫單元測試來驗證程序是否正確呢?下面首先了解MVVM架構,然後通過一個例子來講述我如何在RAC(ReactiveCocoa簡稱)中使用Kiwi來編寫單元測試。
在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位以上。當同時滿足這兩個條件後才能點擊按鈕,否則按鈕是不可點擊的。大家可以從github中下載實例代碼。
首先我們先畫界面,我定義一個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
方法。
如果有些朋友對controller如何管理view生命周期不了解,可以閱讀View Controller Programming Guide for iOS文檔中的A View Controller Instantiates Its View Hierarchy When Its View is Accessed章節
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 [email protected]", ^{ it(@"should return YES", ^{ BOOL result = [DataValidation isValidEmail:@"[email protected]"]; [[theValue(result) should] beYes]; }); }); context(@"when email is samlau163.com", ^{ it(@"should return YES", ^{ BOOL result = [DataValidation isValidEmail:@"samlau163.com"]; [[theValue(result) should] beNo]; }); }); ......省略兩個測試用例 });
前面已經完成了數據綁定和數據校驗邏輯,接下來思考使用哪個類處理用戶名和密碼是否有效才能點擊和點擊按鈕後,如何調用網絡層在來匹配用戶名和密碼,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 [email protected] and password is freedom", ^{ __block BOOL result = NO; it(@"should return signal that value is YES", ^{ viewModel.username = @"[email protected]"; 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 [email protected] and password is samlau", ^{ __block BOOL success = NO; __block NSError *error = nil; it(@"should login successfully", ^{ RACTuple *tuple = [[LoginClient loginWithUsername:@"[email protected]" 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
來測試異步網絡訪問的。詳情可參考Test with Reactivecocoa文章。
如圖所示,輸入正確的用戶名和密碼後,跳轉到一個食物列表頁面,它從服務端抓取圖片、價格和已售份數後以列表的方式顯示。
首先考慮如何設計和實現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]; }); }); });
抓取完數據後,它的數據格式一般都是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]]; }); }); });
完成抓取網絡數據和轉化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
最後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。
ReactiveCocoa
Test with Reactivecocoa
Kiwi
TDD的iOS開發初步以及Kiwi使用入門
Kiwi 使用進階 Mock, Stub, 參數捕獲和異步測試