自鉴iOSReactiveCocoa相关

如何在ReactiveCocoa中写单元测试

2015-09-01  本文已影响5011人  Sam_Lau

现在很多人在开发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瘦身

登录情景

数据绑定 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如何管理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
[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。

扩展阅读

上一篇 下一篇

猜你喜欢

热点阅读