你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 如何在ReactiveCocoa中寫單元測試

如何在ReactiveCocoa中寫單元測試

編輯:IOS開發基礎

現在很多人在開發iOS時都使用ReactiveCocoa,它是一個函數式和響應式編程的框架,使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞消息和解決對象之間狀態與狀態的依賴過多問題。但很多時候使用它之後,如何編寫單元測試來驗證程序是否正確呢?下面首先了解MVVM架構,然後通過一個例子來講述我如何在RAC(ReactiveCocoa簡稱)中使用Kiwi來編寫單元測試。

MVVM架構

166109-81012f4948373da5.png.jpeg


在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瘦身

登錄情景

1478095459922284.gif

如圖所示,這是一個簡單的登錄界面:有用戶名和密碼的兩個輸入框,一個登錄按鈕。用戶輸入完用戶名和密碼後,點擊登錄按鈕後,成功登錄。但這裡有限制條件:用戶名必須滿足郵件的格式和密碼長度必須在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的改變。那麼測試用例可以這樣設計:

166109-683b8d1e185ab6ca.png

數據綁定 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章節


166109-64033d837aa08afb.png

  • 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

測試用例設計如下:

166109-fb67909afc0fcd72.png


然後使用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];
        });
    });

    ......省略兩個測試用例
});

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對象返回。

測試用例設計如下:

166109-e3edf73d59b18147.png

然後使用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模擬服務,所以只設計一個成功的測試用例:


166109-9d6f5119dc46371f.png

然後使用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文章。

抓取網絡數據並顯示情景

抓取網絡數據並顯示情景.gif

如圖所示,輸入正確的用戶名和密碼後,跳轉到一個食物列表頁面,它從服務端抓取圖片、價格和已售份數後以列表的方式顯示。

網絡層測試

首先考慮如何設計和實現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。

擴展閱讀

  • ReactiveCocoa
    Test with Reactivecocoa

  • Kiwi
    TDD的iOS開發初步以及Kiwi使用入門
    Kiwi 使用進階 Mock, Stub, 參數捕獲和異步測試



  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved