如何在ReactiveCocoa中写单元测试

现在很多人在开发iOS时都使用ReactiveCocoa,它是一个函数式和响应式编程的框架,使用Signal来代替KVO、Notification、Delegate和Target-Action等传递消息和解决对象之间状态与状态的依赖过多问题。但很多时候使用它之后,如何编写单元测试来验证程序是否正确呢?下面首先了解MVVM架构,然后通过一个例子来讲述我如何在RAC(ReactiveCocoa简称)中使用Kiwi来编写单元测试。

MVVM架构

MVVM high level

在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如何交互和如何设计和实现哪些类来处理。由于用户名和密码要同时满足验证格式时才能点击登录按钮,所以需要时刻监听usernameTextFieldpasswordTextField的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章节

Loading a view into memory from Apple Document
  • usernameTextField和passwordTextField一定要调用sendActionsForControlEvents方法来通知UI已经更新。
[rootView.usernameTextField sendActionsForControlEvents:UIControlEventEditingChanged];
[rootView.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged];

一开始时,我并没有调用sendActionsForControlEvents方法导致loginViewModel.usernameloginViewModel.password属性并没有更新。当时我开始思考,是不是还需要其他条件还能触发它更新呢?由于我使用UITextFieldrac_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];
}

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

业务逻辑测试

由于这里需要验证用户名和密码,复用性高,我不将处理逻辑放在viewModel中,而是定义一个DataValidation来处理。这里的用户名是邮箱格式,而密码要求长度大于等于6即可,方法如下:

@interface DataValidation : NSObject

+ (BOOL)isValidEmail:(NSString *)data;
+ (BOOL)isValidPassword:(NSString *)password;

@end

测试用例设计如下:

数据验证 Test Case.png

然后使用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类都会调用isValidEmailisValidPassword来数据验证,并将结果包裹成RACSignal对象返回。

测试用例设计如下:

View Model Test Case

然后使用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模拟服务,所以只设计一个成功的测试用例:

Network Test Case.png

然后使用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来测试异步网络访问的。详情可参考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];
        });
    });
});

Model层测试

抓取完数据后,它的数据格式一般都是JSON格式,需要转化为Model方便访问和修改,通常我都使用Mantle来实现。我定义一个FoodModel类:

@interface FoodModel : MTLModel <MTLJSONSerializing>

/*
 @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。

扩展阅读

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容