iOS开发实战:如何在ReactiveCocoa中编写单元测试?

更新时间:2015-09-10 11:05:12点击次数:2146次

现在很多人在开发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方法中进行数据绑定,但需要数据类型转换。示例代码如下:


[cpp] view plaincopy

  1. RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){   

  2.     return [[ViewModel dateFormatter] stringFromDate:date];  

  3. }];  


ViewModel调用dateFormatter进行数据转换,且方法dateFormatter可以复用到其他地方。然后view controller监听view model的dateAdded属性且绑定到label的text属性。


[cpp] view plaincopy

  1. RAC(self.label,text) = RACObserve(self.viewModel,dateAdded);  


现在我们抽象出日期转换到字符串的逻辑到view model,使得代码可以测试和复用,并且帮view controller瘦身。

登录情景


如图所示,这是一个简单的登录界面:有用户名和密码的两个输入框,一个登录按钮。用户输入完用户名和密码后,点击登录按钮后,成功登录。但这里有限制条件:用户名必须满足邮件的格式和密码长度必须在6位以上。当同时满足这两个条件后才能点击按钮,否则按钮是不可点击的。大家可以从Github中下载实例代码。

首先我们先画界面,我定义一个LoginView,将画登录界面的责任都交给它。然后在LoginViewController中的viewDidLoad方法调用buildViewHierarchy加载它。


[cpp] view plaincopy

  1. #pragma mark - Lifecycle  

  2. - (void)viewDidLoad {  

  3.     [super viewDidLoad];  

  4.   

  5.     // build view hierarchy  

  6.     [self buildViewHierarchy];  

  7.     // bind data  

  8.     [self bindData];  

  9.     // handle events  

  10.     [self handleEvents];  

  11. }  

  12.   

  13. - (void)buildViewHierarchy  

  14. {  

  15.     [self.view addSubview:self.rootView];  

  16.     [self.rootView mas_makeConstraints:^(MASConstraintMaker *make) {  

  17.         make.edges.equalTo(self.view);  

  18.     }];  

  19. }  


接下来我们要思考UI如何交互和如何设计和实现哪些类来处理。由于用户名和密码要同时满足验证格式时才能点击登录按钮,所以需要时刻监听usernameTextField和passwordTextField的text属性,对于处理UI交互、数据校验以及转换都交给MVVM架构中ViewModel来处理。于是定义一个LoginViewModel,并继承RVMViewModel,这个RVMViewModel有个active属性来表示viewModel是否处于活跃状态,当active是YES时,更新或显示UI。当active是NO时,不更新或隐藏UI。


[cpp] view plaincopy

  1. @interface LoginViewModel : RVMViewModel  

  2.   

  3. #pragma mark - UI state  

  4. /* 

  5.  @brief 用户名 

  6.  */  

  7. @property (copy, nonatomic) NSString *username;  

  8. /* 

  9.  @brief 密码 

  10.  */  

  11. @property (copy, nonatomic) NSString *password;  

  12.   

  13. #pragma mark - Handle events  

  14. /* 

  15.  @brief 处理用户民和密码是否有效才能点击按钮以及登陆事件 

  16.  */  

  17. @property (nonatomic, strong) RACCommand *loginCommand;  

  18.   

  19. #pragma mark - Methods  

  20. - (RACSignal *)isValidUsernameAndPasswordSignal;  

  21.   

  22. @end  


上面还有一个loginCommand属性和isValidUsernameAndPasswordSignal方法等下会详细介绍。定义LoginViewModel类后,在LoginViewController以组合和委托的方式来使用LoginViewModel并使用Lazy Initialization来初始化它。


[cpp] view plaincopy

  1. @interface LoginViewController ()  

  2.   

  3. #pragma mark - View model  

  4. @property (strong, nonatomic) LoginViewModel *loginViewModel;  

  5.   

  6. @end  

  7.   

  8. @implementation LoginViewController  

  9.   

  10. #pragma mark - Custom Accessors  

  11. - (LoginViewModel *)loginViewModel  

  12. {  

  13.     if (!_loginViewModel) {  

  14.         _loginViewModel = [LoginViewModel new];  

  15.     }  

  16.     return _loginViewModel;  

  17. }  


后调用bindData方法进行数据绑定


[cpp] view plaincopy

  1. - (void)bindData  

  2. {  

  3.     RAC(self.loginViewModel, username) = self.rootView.usernameTextField.rac_textSignal;  

  4.     RAC(self.loginViewModel, password) = self.rootView.passwordTextField.rac_textSignal;  

  5. }  


数据绑定测试

如果usernameTextField.text、passwordTextField.text与loginViewModel.username、loginViewModel.password已经绑定数据,那么usernameTextField.text和passwordTextField.text的数据变动的话,一定会引起loginViewModel.username和loginViewModel.password的改变。那么测试用例可以这样设计:


图:数据绑定Test Case

用kiwi编写测试如下:


[cpp] view plaincopy

  1. SPEC_BEGIN(LoginViewControllerSpec)  

  2.   

  3. describe(@"LoginViewController", ^{  

  4.     __block LoginViewController *controller = nil;  

  5.   

  6.     beforeEach(^{  

  7.         controller = [LoginViewController new];  

  8.         [controller view];  

  9.     });  

  10.   

  11.     afterEach(^{  

  12.         controller = nil;  

  13.     });  

  14.   

  15.     describe(@"Root View", ^{  

  16.         __block LoginView *rootView = nil;  

  17.   

  18.         beforeEach(^{  

  19.             rootView = controller.rootView;  

  20.         });  

  21.   

  22.         context(@"when view did load", ^{  

  23.             it(@"should bind data", ^{  

  24.                 rootView.usernameTextField.text = @"samlau";  

  25.                 rootView.passwordTextField.text = @"freedom";  

  26.   

  27.                 [rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];  

  28.                 [rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];  

  29.   

  30.                 [[controller.loginViewModel.username should] equal:rootView.usernameTextField.text];  

  31.                 [[controller.loginViewModel.password should] equal:rootView.passwordTextField.text];  

  32.             });  

  33.         });  

  34.   

  35.     });  

  36. });  

  37. 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章节。


图:Loading a view into memory from Apple Document


  • usernameTextField和passwordTextField一定要调用sendActionsForControlEvents方法来通知UI已经更新。



[cpp] view plaincopy

  1. [rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];  

  2. [rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];  


一开始时,我并没有调用sendActionsForControlEvents方法导致loginViewModel.username和loginViewModel.password属性并没有更新。当时我开始思考,是不是还需要其他条件还能触发它更新呢?由于我使用UITextField的rac_textSignal属性,于是我就查看它的源代码:


[cpp] view plaincopy

  1. - (RACSignal *)rac_textSignal {  

  2.   @weakify(self);  

  3.   return [[[[[RACSignal  

  4.       defer:^{  

  5.           @strongify(self);  

  6.           return [RACSignal return:self];  

  7.       }]  

  8.       concat:[self rac_signalForControlEvents:UIControlEventEditingChanged |  UIControlEventEditingDidBegin]]  

  9.       map:^(UITextField *x) {  

  10.           return x.text;  

  11.       }]  

  12.       takeUntil:self.rac_willDeallocSignal]  

  13.       setNameWithFormat:@"%@ -rac_textSignal", self.rac_description];  

  14. }  


从源代码可以知道,只有触发UIControlEventEditingChanged或UIControlEventEditingDidBegin事件时才能创建RACSignal对象。


本站文章版权归原作者及原出处所有 。内容为作者个人观点, 并不代表本站赞同其观点和对其真实性负责,本站只提供参考并不构成任何投资及应用建议。本站是一个个人学习交流的平台,网站上部分文章为转载,并不用于任何商业目的,我们已经尽可能的对作者和来源进行了通告,但是能力有限或疏忽,造成漏登,请及时联系我们,我们将根据著作权人的要求,立即更正或者删除有关内容。本站拥有对此声明的最终解释权。

  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息
  • 项目经理 点击这里给我发消息