iOS 單元測(cè)試 - BDD

單元測(cè)試

為什么需要單元測(cè)試

  • 減少代碼中的低級(jí)錯(cuò)誤。
  • 有效的降低bug的出現(xiàn)率。
  • 增強(qiáng)可維護(hù)性。
  • 有助于設(shè)計(jì):寫單元測(cè)試首先給了你一個(gè)如何設(shè)計(jì) API 的清晰視角。
  • 質(zhì)量保證,根據(jù)我的自身經(jīng)歷,讓一個(gè)開發(fā)者記得要測(cè)試所有的特性,在代碼改變后回歸測(cè)試所有的功能以及新增或移除的功能,幾乎是一件不可能的事情。

被測(cè)試的對(duì)象,方法大概分為三種:

  • 有明確的返回值,采用返回值驗(yàn)證法,驗(yàn)證返回值是否符合預(yù)期。
  • 沒有返回值,但方法內(nèi)部修改了對(duì)象的屬性或者狀態(tài),采用狀態(tài)驗(yàn)證法,是否符合預(yù)期。
  • 依賴于外部的類,方法,會(huì)調(diào)用外部的方法,采用行為驗(yàn)證法。

單元測(cè)試可能遇到的問題

  • 測(cè)試上下文有太多依賴,設(shè)計(jì)的耦合性太高。
  • 運(yùn)行的速度緩慢,你的單元測(cè)試中可能存在外部系統(tǒng),列入數(shù)據(jù)庫(kù),網(wǎng)絡(luò)請(qǐng)求,文件系統(tǒng)等。
  • 改變一個(gè)地方,多處測(cè)試受影響,可能是測(cè)試設(shè)計(jì)的問題,也可能是代碼的粒度拆分不夠。
  • 怎樣測(cè)試私有方法——私有方法有太多的行為 。

測(cè)試哪些東西

你在測(cè)試哪個(gè)組件切面(component aspect)?
這個(gè)特性做什么用?你測(cè)試的具體行為需求是什么?
針對(duì)行為的測(cè)試,這是一種行為驅(qū)動(dòng)開發(fā)技術(shù)(BDD),可以參考 unit-testing-tdd-and-bdd。那什么是行為?
你設(shè)計(jì)的App中有一個(gè)對(duì)象,它有一個(gè)接口定義了其方法和依賴關(guān)系。這些方法和依賴,聲明了你對(duì)象的約定。它們定義了如何與你應(yīng)用的其他部分交互,以及它的功能是什么。它們定義了對(duì)象的行為。這同時(shí)也是你的目標(biāo),測(cè)試對(duì)象的行為。
比如點(diǎn)擊按鈕是否觸發(fā)了某個(gè)行為。

怎樣進(jìn)行單元測(cè)試

單元測(cè)試本質(zhì)上來說就是用斷言來判斷對(duì)象是否達(dá)到預(yù)期的行為。
單元測(cè)試的關(guān)注點(diǎn)單一,單元測(cè)試需要保證你每個(gè)測(cè)試用例是針對(duì)一個(gè)單元,而不是一個(gè)有很多復(fù)雜依賴注入的綜合行為。
我們盡可能讓類方法的職責(zé)單一,這樣才能保證變化點(diǎn)都集中在被測(cè)試的單元中。
單元測(cè)試一般比較靜態(tài),它只是驗(yàn)證某一動(dòng)作的正確性。
大部分單元測(cè)試將針對(duì)對(duì)象的狀態(tài),來斷言一個(gè)特定的交互是否發(fā)生,或者一個(gè)特定值是否返回。將依賴提取出來,這可以允許你輕松mock。
注意,你不該將對(duì)象的所有依賴都暴露在頭文件中,尤其是你開始測(cè)試的時(shí)候,這樣看起來很誘人,但會(huì)破壞你類結(jié)構(gòu)的封裝,你的接口應(yīng)該只表述設(shè)計(jì)需求。

  • 充分了解需要測(cè)試的類的行為特性。
  • 對(duì)代碼重構(gòu),針對(duì)這些行為編寫單元測(cè)試。
  • 使用偽造類避免對(duì)其它類的依賴:被測(cè)試的方法需要某個(gè)對(duì)象作為參數(shù),但測(cè)試類中并不關(guān)心對(duì)象的具體實(shí)現(xiàn)。
  • 偽造環(huán)境避免其它環(huán)境的干擾:比如在網(wǎng)絡(luò)請(qǐng)求異步的環(huán)境中,測(cè)試代碼不好寫,可以將該步驟分開,測(cè)試請(qǐng)求網(wǎng)絡(luò)行為,再模擬數(shù)據(jù)返回,測(cè)試網(wǎng)絡(luò)數(shù)據(jù)返回后的行為。也可以采用GHUnit等第三方框架。

單元測(cè)試壞的實(shí)踐

  • 不應(yīng)該測(cè)試構(gòu)造函數(shù)--構(gòu)造函數(shù)中應(yīng)該是實(shí)現(xiàn)類的一些細(xì)節(jié),而我們是針對(duì)對(duì)象的行為做測(cè)試,所以構(gòu)造函數(shù)沒有值得測(cè)的東西。
  • 不要測(cè)試私有方法--私有方法意味著私有,如果你覺得你有必要測(cè)試私有方法,那可能你的私有方法中做的事太多了,從而違背了單一職責(zé)原則。
  • 不要stub私有方法--因?yàn)槟愕乃接蟹椒ㄊ强梢栽陬愔胁唤?jīng)通知自由修改的,當(dāng)stub私有方法后,私有方法修改后可能與你的期望背到而馳,但你的測(cè)試還是會(huì)通過,這是見很可怕的事情。
  • 不要stub第三方庫(kù)--比如你stub了[AFNetworking sendRequest]方法,不需要通過實(shí)際的網(wǎng)絡(luò)調(diào)用是測(cè)試內(nèi)容更單一,當(dāng)你更換了這個(gè)網(wǎng)絡(luò)庫(kù)之后,這個(gè)測(cè)試用例就會(huì)失效,而你實(shí)際上stub的目的就是模擬網(wǎng)絡(luò)請(qǐng)求成功。所以測(cè)試中應(yīng)該封裝一層,來代替那個(gè)庫(kù)的全部功能。

Given / When / Then 模式

將測(cè)試用例分為三個(gè)部分

  • Given 部分,通過創(chuàng)建對(duì)象,活著stub對(duì)象,將測(cè)試的系統(tǒng)設(shè)定到指定的狀態(tài),來設(shè)置測(cè)試的環(huán)境。
  • When 部分包含了我們具體需要測(cè)試的代碼。
  • Then 部分是驗(yàn)證測(cè)試的結(jié)果,是否達(dá)到我們的期望,對(duì)象是否有改變,返回值是否合格等。

下面是家園寶測(cè)試作業(yè)是否正常繼續(xù)下載的例子:

-(void)testResumeWithHomeWorkModel{
  //Given
  if (!self.manager) {
      self.manager = [DownLoadHomeWorkManager manager];
  }
  HomeModel* homeModel = [[HomeModel alloc]init];
  homeModel.pid = @"497";
  //When
  [self.manager resumeTaskWith:homeModel];
  //Then    
  XCTAssertEqual(homeModel.status, HomeworkStateDownloading);
}

Mock

在iOS測(cè)試中的mock框架可以采用OCMock,我們用mock來管理一個(gè)對(duì)象的所有依賴。當(dāng)被測(cè)試的方法里耦合著其它對(duì)象時(shí),但是你不想讓這個(gè)對(duì)象的返回值對(duì)這個(gè)方法有影響,你可以通過mock 的方式返回一個(gè)默認(rèn)值。
另外,我們的測(cè)試代碼中不能過度的使用mock,mock除去了被測(cè)試對(duì)象以外的其它對(duì)象,這樣其它對(duì)象修改了之后,這個(gè)被測(cè)試的對(duì)象就不能自動(dòng)失敗。

單元測(cè)試的要求

  • 測(cè)試用例應(yīng)該是自解釋且獨(dú)立的:每個(gè)測(cè)試用例應(yīng)該不依賴于其它方法的結(jié)果作為輸入,沒有網(wǎng)絡(luò)請(qǐng)求,沒有數(shù)據(jù)庫(kù)操作,保證其原子性。
  • 測(cè)試方法需要解釋測(cè)試的目的:如 -(void)testObject 就不是一種規(guī)范的寫法,每個(gè)方法的目標(biāo)應(yīng)該是單一的,大多數(shù)每個(gè)方法里都有一個(gè)斷言。
  • 斷言語(yǔ)句需要解釋測(cè)試者的用途:如XCTAssertNotNil,[xxx should]beNil],等等。
  • 判斷某個(gè)測(cè)試是否成功是檢測(cè)方法影響的數(shù)據(jù)有沒有合理的變化:由于單元測(cè)試是使用斷言來判斷的,單元測(cè)試中不會(huì)對(duì)顯示層進(jìn)行約束,所以限定了單元測(cè)試的范圍,即引起數(shù)據(jù)的變化。
  • 對(duì)所有暴露的屬性和方法提供測(cè)試,私有方法則不必:測(cè)試私有方式可以通過子類化,設(shè)計(jì)分類,kvo等方式獲取私有或者內(nèi)部對(duì)象。
  • 變化需要新測(cè)試的支持:當(dāng)對(duì)外的接口的實(shí)現(xiàn)發(fā)生變化時(shí),需要編寫新的測(cè)試。
  • 發(fā)現(xiàn)bug 并修復(fù)后,為了確保修復(fù)時(shí)成功的,需要進(jìn)行單元測(cè)試。
  • 測(cè)試有其他依賴時(shí)需要避免其它依賴的副作用,可以采用依賴注入的方法, 依賴注入 ,具體可以使用mock 或者真實(shí)對(duì)象注入。
  • 對(duì)每一個(gè)功能類都要做單元測(cè)試。
  • 單元測(cè)試需要描述和記錄代碼需要實(shí)現(xiàn)的所有需求。

單元測(cè)試框架

XCTest

XCTest 是iOS自帶的一個(gè)測(cè)試框架,相比于其他第三方集成度高,能滿足大部分測(cè)試需求。但是并沒有提供mock的功能。

OCMock

OCMock 是一個(gè)OC的模擬對(duì)象庫(kù),他提供了關(guān)于mock 和 stub 的功能,可以和XCTest一起使用。它看起來像這樣。

- (void)testAddDownLoadHomeWorkModel{
   if (!self.manager) {
       self.manager = [DownLoadHomeWorkManager manager];
   }
   HomeModel* homeModel = [[HomeModel alloc]init];
   //mock 出一個(gè)dataCenter
   id dataCenter = OCMClassMock([HomeworkDataCenter class]);
   self.manager.dataCenter = dataCenter;
   homeModel.pid = @"497";
   //該方法被調(diào)用時(shí)返回1
   OCMStub([dataCenter insertHomeModel:homeModel]).andReturn(1);
   [self.manager addDownLoadTask:homeModel];
   XCTAssertEqual(homeModel.status, HomeworkStateDownloading);
}

Kiwi

Kiwi 是一個(gè)行為驅(qū)動(dòng)開發(fā)(BDD)的框架,它旨在解決具體問題,幫助開發(fā)人員確定應(yīng)該測(cè)什么內(nèi)容。你不應(yīng)該關(guān)注于測(cè)試,而是應(yīng)該關(guān)注于行為。
該框架相比iOS自帶的XCTest,它的語(yǔ)法更類似于自然語(yǔ)言,易讀性強(qiáng)。
Kiwi 更多使用方法點(diǎn)擊 這里

SPEC_BEGIN(First)
   describe(@"First", ^{
    context(@"create a string", ^{
        __block NSString * name = nil;
        beforeEach(^{
            name = @"aa";
        });
        it(@"name should be aa", ^{
            [[name shouldNot]beNil];
        }); 
    });
});
SPEC_END

測(cè)試實(shí)例

Unitest.png

首先,我們來看一下iOS的UIViewController。對(duì)代碼分析時(shí),發(fā)現(xiàn)大量的的邏輯都被寫在 .m 文件里,我們知道,UIViewController 在 .h 里面暴露的方法很少,可是 .m 大量的邏輯單元測(cè)試又不能不做,這就相當(dāng)于要對(duì)代碼中的private 方法進(jìn)行測(cè)試。
進(jìn)一步分析發(fā)現(xiàn),如果在ViewController 中 添加一個(gè)ViewModel層,將UIViewController 里的業(yè)務(wù)邏輯放入中間層,該層可以負(fù)責(zé)網(wǎng)絡(luò)的請(qǐng)求,數(shù)據(jù)的處理等。一方面會(huì)使ViewController 更加簡(jiǎn)潔和實(shí)現(xiàn)單一原則,另一方面保證了邏輯的可能性,該中間層會(huì)對(duì)ViewController 暴露一些接口。在MVC的設(shè)計(jì)模式中,ViewController 承受了太多的任務(wù)導(dǎo)致測(cè)試的難度增加,將ViewController 拆分(MVVM)就會(huì)更加有利于單元測(cè)試。

describe(@"Bind ViewModel", ^{
    __block WrapJSMessageView* replyView = nil;
    __block WrapJSMessageViewModel* replyViewModel = nil;
    replyView = [[WrapJSMessageView alloc]init];
    replyViewModel = [[WrapJSMessageViewModel alloc]init];
    replyView.replyViewModel = replyViewModel;
    
    context(@"Test Text Binding  ", ^{
        it(@"Image Should NotNil", ^{
            [[replyViewModel.image shouldNot]beNil];
        });
        
        //select Image;
        {
            UIImage* image = [UIImage new];
            NSDictionary* dict = @{};
            [dict stub:@selector(objectForKey:) andReturn:image withArguments:@"UIImagePickerControllerOriginalImage"];
            [replyView imagePickerController:[UIImagePickerController new] didFinishPickingMediaWithInfo:dict];
            it(@"select Image", ^{
                [[replyViewModel.image should]equal:image];
            });
        }
        
        // clear text
        {
            it(@"After reset Image should Be reset", ^{
                [[replyView should]receive:@selector(resetReplyView)];
                replyViewModel.text = nil;
            });
        }
        
        
        UIButton* sendBtn = nil;
        [UIView getViewByTitle:@"發(fā)送" rootView:replyView resultView:&sendBtn];
        
        // test Click ReplyBtn
        {
            it(@"Get Send Btn", ^{
                [[sendBtn shouldNot]beNil];
            });
        }
        // Send Btn should Be disable
        {
            it(@"Send Btn should Disable", ^{
                [[theValue(sendBtn.enabled)should] beFalse];
            });
        }
        
        //Send Btn should Be disable
        {
            it(@"Send Btn should Enable", ^{
                replyViewModel.text = @"222";
                [[theValue(sendBtn.enabled)should] beFalse];
            });
        }
        
        // ReplyBtnClicked
        {
            [[replyViewModel.replyCommand should]receive:@selector(execute:)];
            
            it(@"Send Should Invalid", ^{
                [sendBtn sendActionsForControlEvents:UIControlEventTouchUpInside];
            });
        }
        
    });
    
})

DetailNewViewController

describe(@"DetailNewViewController", ^{
    __block DetailNewViewController* detail = [[DetailNewViewController alloc]init];
    context(@"initial with valid post_id", ^{
        
        id viewModel = OCMClassMock([PostDetailViewModel class]);
        detail.viewModel = viewModel;
        detail.postid = @"100";
        it(@"initWithPostid should be invoked", ^{
            OCMVerify([viewModel initWithPostId:[OCMArg any]]);
        });
    });
    
    context(@"Inital", ^{
        
        PostDetailViewModel* viewModel = [[PostDetailViewModel alloc]initWithPostId:@"1"];
        
        // 測(cè)試獲取帖子數(shù)據(jù)
        {
            
            MJRefreshGifHeader* header = nil;
            //獲取header
            [UIView getRefeshHeader:detail.view resultView:&header];
            it(@"RefreshController should not be nil", ^{
                [[header shouldNot]beNil];
            });
            
            
            RACCommand* fetchRawCommand = OCMClassMock([RACCommand class]);
            viewModel.fetchRawDataCommand = fetchRawCommand;
            
            [detail viewDidLoad];
            it(@"should Request Post Data", ^{
                OCMVerify([header beginRefreshing]);
            });
        }
        
        
        //測(cè)試獲取評(píng)論列表
        
        {
            NSDictionary* returnData = @{
                                         @"ret":@(1),
                                         @"retCode":@(1),
                                         };
            
            detail.viewModel = viewModel;
            id fetchReplyCommand = OCMClassMock([RACCommand class]);
            viewModel.fetchReplyListCommand = fetchReplyCommand;
            
            [detail showWithDict:returnData];
            it(@"Then Request ReplyList Data", ^{
                OCMVerify([fetchReplyCommand execute:[OCMArg any]]);
            });
        }
    });
    
    context(@"Test Click Collect Btn", ^{
        
        detail.isIntersting = NO;
        detail.contentData = [NSDictionary mock];
        PostDetailViewModel* viewModel = [[PostDetailViewModel alloc]init];
        id command = OCMClassMock([RACCommand class]);
        viewModel.collectCommand = command;
        
        detail.viewModel = viewModel;
        [detail soucangBtnDidClicked];
        
        it(@"Send NetWork should Raised ", ^{
            OCMVerify([command execute:[OCMArg any]]);
        });
        
    });
    
    context(@"Test Click Uncollect Btn", ^{
        detail.isIntersting = YES;
        detail.contentData = [NSDictionary mock];
        PostDetailViewModel* viewModel = [[PostDetailViewModel alloc]init];
        id command = OCMClassMock([RACCommand class]);
        viewModel.unCollectCommand = command;
        
        detail.viewModel = viewModel;
        [detail soucangBtnDidClicked];
        
        it(@"Uncollected should Raised ", ^{
            OCMVerify([command execute:[OCMArg any]]);
        });
    });
    
    
    context(@"Test NavgationItem ", ^{
        UIView* title = detail.titleSegment;
        it(@"should Not Nil", ^{
            [[title shouldNot]beNil];
        });
        
        it(@"should Be UISegmentControl", ^{
            [[title should]beKindOfClass:[UISegmentedControl class]];
        });
        UISegmentedControl* segTitle = (UISegmentedControl*)title;
        it(@"should have Three Segment ", ^{
            [[theValue(segTitle.numberOfSegments) should]equal:@(3)];
        });
    });
    
})

DetailNewViewController+Spec.h

#import "DetailNewViewController.h"
                   
@interface DetailNewViewController (Spec)
   
@property(nonatomic,assign)NSInteger  isIntersting;
   
@property(nonatomic , strong) NSDictionary* contentData;
   
@property(nonatomic , strong) UISegmentedControl* titleSegment;
   
-(void)showWithDict:(NSDictionary*)dict;
   
@end

// UIView 的分類

#import "UIView+Spec.h"
#import "MJRefresh.h"
@class MJRefreshGifHeader;
   
@implementation UIView (Spec)
   
+(void)getRefeshHeader:(UIView*)rootView resultView:(UIView**)result{
   for(UIView * view in rootView.subviews){
       if ([view isKindOfClass:[MJRefreshGifHeader class]]) {
           *result = view;
       }else {
           [self getRefeshHeader:view resultView:result];
       }
   }
}
   
+(void)getViewByTitle:(NSString*)title rootView:(UIView*)rootView resultView:(UIView**)result{
   
   for(UIView * view in rootView.subviews){
       if ([view isKindOfClass:[UIButton class]]&&[[(UIButton*)view titleLabel].text isEqualToString:title]) {
           *result = view;
       }else {
           [self getViewByTitle:title rootView:view resultView:result];
       }
   }
}
@end

BDD實(shí)例

我遇到的問題

剛開始做單元測(cè)試的時(shí)候,根本無法下手,帖子模塊的版本迭代頻繁,業(yè)務(wù)邏輯復(fù)雜,代碼行數(shù)達(dá)到2400行左右。再看代碼結(jié)構(gòu)相當(dāng)混亂。帖子模塊所有的數(shù)據(jù),包括從網(wǎng)絡(luò)的發(fā)起,數(shù)據(jù)的接收,界面的顯示都混雜在一起,只有少量的view 單獨(dú)抽了出去,即使是封裝的view,數(shù)據(jù)的顯示還是在帖子的控制器中做的。這真的是MVC(Massive-View-Controller)了。為了方便測(cè)試,先找?guī)讉€(gè)行為特性測(cè)起來,我把網(wǎng)絡(luò)數(shù)據(jù)的請(qǐng)求和接受,全部封裝到帖子的ViewModel中,抽離出回復(fù)框,并給這個(gè)view配備了一個(gè)ViewModel(因?yàn)榛貜?fù)框中也有不少的邏輯),控制器只需要新建并添加就ok了。這樣針對(duì)回復(fù)框的一些行為就可以提取出來測(cè)了。帖子頁(yè)一些行為可以在ViewModel 中測(cè)試。
在測(cè)試的過程中,由于大量的原生數(shù)據(jù)的顯示邏輯都在帖子頁(yè)的ViewController中,而在測(cè)試這個(gè)控制器的一些行為時(shí),無法提供帖子的原生數(shù)據(jù),或者說因?yàn)樵鷶?shù)據(jù)格式復(fù)雜而難以高效的注入,導(dǎo)致測(cè)試的時(shí)程序崩潰。經(jīng)過思考,覺得還是應(yīng)該將所有的數(shù)據(jù)交給ViewModel 管理,ViewController或View 應(yīng)該僅僅和ViewModel 進(jìn)行數(shù)據(jù)上的綁定。這樣在測(cè)試ViewContrllor時(shí)就不會(huì)對(duì)數(shù)據(jù)有過多的依賴。在測(cè)試ViewModel時(shí)也能更集中的測(cè)試數(shù)據(jù)的有效性。

總結(jié)

在做單元測(cè)試的時(shí)候,更多思考一個(gè)對(duì)象的行為,它的接口應(yīng)該如何,并減少對(duì)實(shí)現(xiàn)的關(guān)注。這樣你會(huì)有更加健壯的代碼,以及同樣杰出的套件。單元測(cè)試的代碼簡(jiǎn)單,但是寫好單元測(cè)試卻不是一件簡(jiǎn)單的事,對(duì)程序員的代碼質(zhì)量要求較高,如何有效的組織行為就考驗(yàn)程序員的水平了。從現(xiàn)在開始,讓單元測(cè)試來幫你描述代碼的行為。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容