iOS開發中的單元測試(三)——URLManager中的測試用例解析

URLManager是一個基於UINavigationController和UIViewController,以URL Scheme爲設計基礎的導航控件,目的是實現ViewController的鬆耦合,不依賴。git

準備框架,定義基類

首先按照以前的兩篇文章介紹的方法導入單元測試框架和匹配引擎框架,創建好測試Target,並配置編譯選項。github

定義測試用例基類:UMTestCase(代碼1),其餘用例所有繼承自UMTestCase。數組

#import <GHUnitIOS/GHTestCase.h>
@interface UMTestCase : GHTestCase
@end

代碼1,UMTestCase,用例基類數據結構

構建用例

URLManager工具類(UMTools)測試用例(UMToolsTestCase)。UMTools中擴展了NSURL,NSString和UIView,方法涉及到給URL添加QueryString和從QueryString中讀取參數,對字符串作子串判斷,進行URL的編碼和解碼,對UIView的x,y,width和height的直接讀寫等。須要在用例中定義測試過程當中會使用到屬性(代碼2), 並在setUpClass中初始化他們(代碼3)。app

代碼2,定義屬性框架

// 普通字符串,帶有字母和數字
@property   (strong, nonatomic)     NSString    *string;
// 普通字符串,僅帶有字母
@property   (strong, nonatomic)     NSString    *stringWithoutNumber;
// 將被作URLEncode的字符串,含有特殊字符和漢字
@property   (strong, nonatomic)     NSString    *toBeEncode;
// 把 toBeEncode 編碼後的串
@property   (strong, nonatomic)     NSString    *encoded;
// 普通的URL,帶有QueryString
@property   (strong, nonatomic)     NSURL       *url;
// 去掉上邊一個URL的QueryString
@property   (strong, nonatomic)     NSURL       *noQueryUrl;
// 一個普通的UIView
@property   (strong, nonatomic)     UIView      *view;
(void)setUpClass
{
    self.string                 = @"NSString For Test with a number 8848.";
    self.stringWithoutNumber    = @"NSString For Test.";
    self.toBeEncode             = @"~!@#$%^&amp;*()_+=-[]{}:;\"'<>.,/?123qwe漢字";
    self.encoded                = @"%7E%21%40%23%24%25%5E%26%2A%28%29_%2B%3D-%5B%5D%
                    7B%7D%3A%3B%22%27%3C%3E.%2C%2F%3F123qwe%E6%B1%89%E5%AD%97";
    self.url                    = [NSURL URLWithString:@"http://example.com
                    /patha/pathb/?p2=v2&amp;p1=v1"];
    self.noQueryUrl             = [NSURL URLWithString:@"http://example.com
                    /patha/pathb/"];
    self.view                   = [[UIView alloc] initWithFrame:CGRectMake(10.0f, 
                    10.0f, 100.0f, 100.f)];
}

代碼3,初始化屬性函數

使用單元測試框架中的斷言處理簡單用例

單元測試是白盒測試,要作到路徑覆蓋(代碼4)。 對「ContainsString」的測試進行正向和反向兩種狀況(即YES和NO兩種返回結果)。工具

#pragma mark - UMString

- (void)testUMStringContainsString
{
    NSString *p = @"For";
    NSString *np = @"BAD";
    GHAssertTrue([self.string containsString:p],
                 @"\"%@\" should contains \"%@\".",
                 self.string, p);
    GHAssertFalse([self.string containsString:np],
                  @"\"%@\" should not contain \"%@\".",
                  self.string, p);

代碼4,字符串測試用例單元測試

同時單元測試又要對功能負責,所以在路徑覆蓋以外還要儘可能照顧到完整的功能。例如,對URLEncode的測試(代碼5),要對儘可能全面的特殊字符進行測試,而不是從源碼實現中取出枚舉的字符。測試

(void)testUrlencode
{
    GHAssertEqualStrings([self.toBeEncode urlencode], self.encoded,
                         @"URLEncode Error.",
                         self.toBeEncode, self.encoded);
    GHAssertEqualStrings([self.encoded urldecode], self.toBeEncode, 
                         @"URLDecode Error.",
                         self.encoded, self.toBeEncode);
}

代碼5,URLEncode測試用例

在進行這個測試以前,urlencode的實現忽視了對「~」的編碼,正是因爲單元測試用例所取的特殊字符是單獨列舉,並不是從實現枚舉中獲取,檢查出了這個錯誤。

引入匹配引擎,使用匹配引擎默認規則

前文提到過匹配引擎可使測試用例中的斷言更加豐富,URLManager的用例中也使用了匹配引擎:OCHamcrest。

在此前的介紹中提到,引入OCHamcrest能夠經過定義 HC_SHORTHAND 來開啓匹配引擎的簡寫模式。由於開啓簡寫模式後匹配規則中的「containsString」規則和上述例子(代碼5)中的「containsString:」方法命名衝突,致使測試程序沒法正常運行,因此這個工程直接使用了相似 HC_asserTaht 這樣帶有HC前綴的完整命名。

我建議使用匹配引擎的開發者謹慎開啓簡寫功能,OCHamcrest的匹配規則簡寫一般是很常見的單詞,很是容易與工程中的類定義或方法定義重名。即便當下沒有規則和方法名發生衝突,隨着工程代碼量的增長,一旦出現命名衝突的狀況,重構的成本將很是高。

匹配引擎能夠提供更豐富的斷言,最簡單的例如,URLManager的UMURL擴展支持向一個URL上添加參數,對這個方法測試斷言就用到了匹配某個字符串是否包含某子串的規則(代碼6)。

#pragma mark - UMURL

- (void)testAddParams
{
    NSURL *queryUrl = [self.noQueryUrl addParams:@{@"p1":@"v1",@"p2":@"v2"}];
    HC_assertThat(queryUrl.absoluteString, HC_containsString(@"p1=v1"));
    HC_assertThat(queryUrl.absoluteString, HC_containsString(@"p2=v2"));
}

代碼6,URL參數測試用例

匹配規則中的陷阱

因爲匹配規則的粒度較細,因此對於某些運行結果須要考慮到多種狀況,不然正常的結果也可能會斷言失敗。

例如測試用例指望獲得一個空容器(例如:NSArray),而SDK則認爲這個容器已經沒有存在的必要而釋放了他,返回的是一個nil。對removeAllSubviews的測試中,對一個view調用removeAllSubviews方法,指望view.subviews爲空。在SDK 6.x甚至SDK 7 DP1以前,都是沒問題的,但在SDK 7 DP3中,SDK會把全部清空的容器和對象釋放,以回收系統資源。在這種條件下view.subviews返回的就是nil,若是隻是作相似HC_empty()這樣的匹配,斷言會失敗,因此在斷言以前作一個subviews屬性的空判斷(代碼7)。

(void)testRemoveAllSubviews
{
    UIView *subViewA = [[UIView alloc] init];
    UIView *subViewB = [[UIView alloc] init];

    [self.view addSubview:subViewA];
    [self.view addSubview:subViewB];
    HC_assertThat(self.view.subviews, HC_containsInAnyOrder(subViewA, subViewB, nil));

    [self.view removeAllSubviews];
    if (nil != self.view.subviews) {
        HC_assertThat(self.view.subviews, HC_empty());
    }
}

代碼7,removeAllSubviews用例

另外,在默認匹配規則中會有一些容易產生歧義的命名,以collection的containsInAnyOrder爲例:匹配對象是一個collection對象(也就是遵循NSFastEnumeration協議的對象,NSArray等),給出若干個匹配規則或元素。期待這個規則匹配該對象是否包含給出的若干元素,且不關心順序。但在實際測試過程當中會發現,這個規則要求給出的元素必須是該collection對象的完備集,也就是說要求給出的元素列表和要匹配的容器對象中的元素必須是相等的結合,但容許不關注順序。

對UMNavigationController的測試中,須要判斷增長一項URL Mapping是否生效,若是使用該匹配規則,就不能單純判斷config是否包含增量的URL,要斷言成功必須連同此前config屬性初始化寫入的值一塊兒考慮,使用一個完整的元素集合進行匹配(代碼8)。

(void)testAddConfig
{
    [UMNavigationController setViewControllerName:@"ViewControllerA" forURL:@"<br style="margin: 0px; border: 0px; padding: 0px;" />um://viewa2"];
    NSMutableDictionary *config = [UMNavigationController config];
    NSLog(@"%@", [config allKeys]);
    HC_assertThat([config allKeys],
                  HC_containsInAnyOrder(HC_equalTo(@"um://viewa2"), HC_equalTo(@"<br style="margin: 0px; border: 0px; padding: 0px;" />um://viewa"),
                                        HC_equalTo(@"um://viewb"), nil));
    GHAssertEqualStrings(config[@"um://viewa2"], @"ViewControllerA",
                         @"config set error.");
}

代碼8,AddConfig用例

自建匹配規則

上述例子代表匹配規則每每沒法剛好知足測試需求,須要對默認規則進行升級。

升級一個匹配規則,首先閱讀OCHamcrest默認規則源碼,找到沒法知足需求的代碼。上述HC_containsInAnyOrder的例子中,個性需求是某個collection是否包含某幾個元素(而非完整集合),而默認規則只能匹配完整集合。閱讀源碼(代碼9)能夠發現,在maches:describingMismatchTo:函數中,對規則對象的collection屬性(要進行匹配的容器對象)進行遍歷,並逐個調用matches:方法。matches:方法中針對每一個collection屬性中的元素遍歷匹配規則集合(matchers),並從規則集合(matchers)中移除匹配成功的規則。當給出的規則集合(matchers)所有成功匹配過以後,matchers屬性已經爲空。若此時對collection屬性的遍歷繼續進行,matches:方法就不會進入匹配邏輯,直接跳出循環返回NO,致使匹配失敗。

(BOOL)matches:(id)item
{
    NSUInteger index = 0;
    for (id<HCMatcher> matcher in matchers)
    {
        if ([matcher matches:item])
        {
            [matchers removeObjectAtIndex:index];
            return YES;
        }
        ++index;
    }
    [[mismatchDescription appendText:@"not matched: "] appendDescriptionOf:item];
    return NO;
}

- (BOOL)matches:(id)collection describingMismatchTo:(id<HCDescription>)<br style="margin: 0px; border: 0px; padding: 0px;" />mismatchDescription
{
    if (![collection conformsToProtocol:@protocol(NSFastEnumeration)])
    {
        [super describeMismatchOf:collection to:mismatchDescription];
        return NO;
    }

    HCMatchingInAnyOrder *matchSequence =
        [[HCMatchingInAnyOrder alloc] initWithMatchers:matchers 
                                   mismatchDescription:mismatchDescription];
    for (id item in collection)
        if (![matchSequence matches:item])
            return NO;

    return [matchSequence isFinishedWith:collection];
}

代碼9,HC_containsInAnyOrder規則中的兩個核心方法

咱們的需求是,當匹配規則列表所有成功匹配以後就是這次匹配成功的標誌。因此須要修改matches:方法中的匹配邏輯,當匹配列表爲空則返回YES。

升級方案是繼承HCIsCollectionContainingInAnyOrder建立一個新的匹配規則類HCIsCollectionHavingInAnyOrder;從新定義匹配規則HC_hasInAnyOrder;重寫調用matches:方法的matches:describingMismatchTo:方法(代碼10);更新的核心是定義一個HCMatchingInAnyOrderEx類,按照個性需求定義matches:方法(代碼11)。使用這個修改過的匹配規則就能夠判斷一個Collection是否包含某個幾個元素了。

@implementation HCIsCollectionHavingInAnyOrder

- (BOOL)matches:(id)collection describingMismatchTo:(id<HCDescription>)<br style="margin: 0px; border: 0px; padding: 0px;" />mismatchDescription
{
    if (![collection conformsToProtocol:@protocol(NSFastEnumeration)])
    {
        [super describeMismatchOf:collection to:mismatchDescription];
        return NO;
    }

    HCMatchingInAnyOrderEx *matchSequence =
    [[HCMatchingInAnyOrderEx alloc] initWithMatchers:matchers
                                 mismatchDescription:mismatchDescription];
    for (id item in collection)
        if (![matchSequence matches:item])
            return NO;

    return [matchSequence isFinishedWith:collection];
}

@end

id<HCMatcher> HC_hasInAnyOrder(id itemMatch, ...)
{
    NSMutableArray *matchers = [NSMutableArray arrayWithObject:HCWrapInMatcher<br style="margin: 0px; border: 0px; padding: 0px;" />(itemMatch)];

    va_list args;
    va_start(args, itemMatch);
    itemMatch = va_arg(args, id);
    while (itemMatch != nil)
    {
        [matchers addObject:HCWrapInMatcher(itemMatch)];
        itemMatch = va_arg(args, id);
    }
    va_end(args);

    return [HCIsCollectionHavingInAnyOrder isCollectionContainingInAnyOrder:matchers];
}

代碼10,HCIsCollectionHavingInAnyOrder實現

(BOOL)matches:(id)item
{
    NSUInteger index = 0;
    BOOL matched = (0 >= [self.matchers count]);
    for (id<HCMatcher> matcher in self.matchers)
    {
        if ([matcher matches:item]) {
            [self.matchers removeObjectAtIndex:index];
            matched = YES;
            return YES;
        }
        ++index;
    }
    return matched;
}

代碼11,更新過的matches:方法

(void)testAddConfig
{
    [UMNavigationController setViewControllerName:@"ViewControllerA" forURL:@"um://<br style="margin: 0px; border: 0px; padding: 0px;" />viewa2"];
    NSMutableDictionary *config = [UMNavigationController config];
    HC_assertThat([config allKeys],
                  HC_hasInAnyOrder(HC_equalTo(@"um://viewa2"), nil));
    GHAssertEqualStrings(config[@"um://viewa2"], @"ViewControllerA",
                         @"config set error.");
}

代碼12,使用新規則的測試用例

另外一個方面,在測試過程當中會出現各類邏輯,有時默認規則根本沒法覆蓋,須要徹底自建規則。例如對CGPoint和CGSize的相等匹配,如代碼13中對UMView的size和origin方法測試。OCHamcrest的默認規則中根本沒有提供任何針對CGPoint和CGSize兩個結構體的匹配規則,因此要完成這個測試就須要本身定義針對這兩種數據結構的匹配規則。

#pragma mark - UMView

    HC_assertThat(NSStringFromCGSize(self.view.size),
                  HC_equalToSize(self.view.frame.size));
    HC_assertThat(NSStringFromCGPoint(self.view.origin),
                  HC_equalToPoint(CGPointMake(self.view.frame.origin.x, self.<br style="margin: 0px; border: 0px; padding: 0px;" />view.frame.origin.y)));

代碼13,UMView測試用例片斷

自定義匹配規則的詳細說明能夠參見上一篇《iOS開發中的單元測試(二)》,本文只對開發自定義規則中遇到的問題和須要特殊處理的方面進行解釋。

OCHamcrest的匹配規則要求被匹配的必須是一個有強引用的對象,因此當被匹配的是一個struct結構(如CGPoint)須要進行一次轉換,如代碼14中定義的這個規則擴展——OBJCEXPORT id HCequalToPoint(CGPoint point)。 在CGPoint相等匹配的規則中,須要先把CGPoint轉爲字符串後傳入斷言方法,規則會把這個字符串儲存起來,並與後續給出的CGPoint進行比較。匹配引擎對傳入的須要進行匹配的參數類型沒作任何限制,因此規則能夠直接傳入CGPoint。

開發自定義規則通常建議同時定義SHORTHAND,即便當前單元測試中不會用到(例如本文中的測試),但這個規則被其餘複用的時候,可能會用到SHORTHAND命名。

#import <OCHamcrestIOS/HCBaseMatcher.h>

OBJC_EXPORT id<HCMatcher> HC_equalToPoint(CGPoint point);

#ifdef HC_SHORTHAND
#define equalToPoint HC_equalToPoint
#endif

@interface HCIsEqualToPoint : HCBaseMatcher

+ (id)equalToPoint:(CGPoint)point;
- (id)initWithPoint:(CGPoint)point;

@property (nonatomic, assign)       CGFloat     x;
@property (nonatomic, assign)       CGFloat     y;

@end

代碼14,擴展匹配規則HC_equalToPoint定義

在匹配規則的過程當中,有一個點須要特別注意,即對匹配對象類型和完整性的判斷。每每開發者把注意力都放在對對象值的匹配上,而忽略了類型和完整性這類判斷,最終致使整個用例運行失敗,但沒法準肯定位出錯的位置。上面提到的對subviews是否爲空的判斷也是這樣的一個例子。因此在自定義的匹配規則中就須要考慮到這方面的問題,如代碼15的matches:方法中,先要對傳入的泛型對象item校驗是否爲字符串,後再轉化爲CGPoint對象,並進行相應比對。示例中給出的是一種較簡單的狀況,在更復雜的狀況下,除了對泛型對象的類進行校驗,還要校驗其是否響應某方法,屬性類型,空判斷,等。

#import "HCIsEqualToPoint.h"
#import <OCHamcrestIOS/HCDescription.h>

id <HCMatcher> HC_equalToPoint(CGPoint point)
{
    return [HCIsEqualToPoint equalToPoint:point];
}

@implementation HCIsEqualToPoint

+ (id)equalToPoint:(CGPoint)point
{
    return [[self alloc] initWithPoint:point];
}

- (id)initWithPoint:(CGPoint)point
{
    self = [super init];
    if (self) {
        self.x = point.x;
        self.y = point.y;
    }
    return self;
}

- (BOOL)matches:(id)item
{
    if (! [item isKindOfClass:[NSString class]]) {
        return NO;
    }
    CGPoint point = CGPointFromString((NSString *)item);

    return (point.x == self.x &amp;&amp; point.y == self.y);
}

- (void)describeTo:(id<HCDescription>)description
{
    [description appendText:@"Point not equaled."];
}

@end

代碼15,擴展匹配規則HC_equalToPoint實現

一個操做多個測試方法

以上提到的幾個例子中所測試的都是很是簡單的操做,因此一個測試方法覆蓋了一個或多個操做,但對於較複雜的操做,每每須要多個測試方法,按部就班的斷言。例如測試經過URL生成UMViewController的用例,生成一個UMViewController實例由簡單到複雜能夠有三種簡單方式:簡單的URL生成,帶參數的URL生成和帶Query字典的URL生成,此外還有URL參數和Query字典共用的方式。因此對於這個操做至少須要使用4個測試方法(代碼16)分別進行測試。

(void)testViewControllerForSimpleURL
{
    self.viewControllerA = (ViewControllerA *)[self.navigator
                                               viewControllerForURL:
                                               [NSURL URLWithString:@"um://viewa"]
                                               withQuery:nil];

    HC_assertThat(self.viewControllerA, HC_instanceOf([UMViewController class]));
    HC_assertThat(self.viewControllerA, HC_isA([ViewControllerA class]));
}

- (void)testViewControllerForURLWithArgs
{
    self.viewControllerA = (ViewControllerA *)[self.navigator
                            viewControllerForURL:[NSURL URLWithString:@"um://viewa?<br style="margin: 0px; border: 0px; padding: 0px;" />p1=v1&amp;p2=v2"]
                            withQuery:nil];

    HC_assertThat(self.viewControllerA, HC_instanceOf([UMViewController class]));
    HC_assertThat(self.viewControllerA, HC_isA([ViewControllerA class]));

    HC_assertThat([self.viewControllerA.params allKeys], HC_containsInAnyOrder<br style="margin: 0px; border: 0px; padding: 0px;" />(@"p1", @"p2", nil));
    GHAssertEqualStrings(self.viewControllerA.params[@"p1"], @"v1", @"param error.");
    GHAssertEqualStrings(self.viewControllerA.params[@"p2"], @"v2", @"param error.");
}

- (void)testViewControllerWithQuery
{
    self.viewControllerA = (ViewControllerA *)[self.navigator
                                     viewControllerForURL:
                                    [NSURL URLWithString:@"um://viewa"]
                                    withQuery:@{@"k1":@"v1", @"k2":@"v2"}];

    HC_assertThat([self.viewControllerA.query allKeys], HC_containsInAnyOrder<br style="margin: 0px; border: 0px; padding: 0px;" />(@"k1", @"k2", nil));
    GHAssertEqualStrings(self.viewControllerA.query[@"k1"], @"v1", @"param error.");
    GHAssertEqualStrings(self.viewControllerA.query[@"k2"], @"v2", @"param error.");
}

- (void)testViewControllerForURLAndQuery
{
    self.viewControllerA = (ViewControllerA *)[self.navigator
                                 viewControllerForURL:
                                [NSURL URLWithString:@"um://viewa?p1=v1&amp;p2=v2"]
                                withQuery:@{@"k1":@"v1", @"k2":@"v2"}];

    HC_assertThat([self.viewControllerA.params allKeys], HC_containsInAnyOrder<br style="margin: 0px; border: 0px; padding: 0px;" />(@"p1", @"p2", nil));
    GHAssertEqualStrings(self.viewControllerA.params[@"p1"], @"v1", @"param error.");
    GHAssertEqualStrings(self.viewControllerA.params[@"p2"], @"v2", @"param error.");

    HC_assertThat([self.viewControllerA.query allKeys], HC_containsInAnyOrder<br style="margin: 0px; border: 0px; padding: 0px;" />(@"k1", @"k2", nil));
    GHAssertEqualStrings(self.viewControllerA.query[@"k1"], @"v1", @"param error.");
    GHAssertEqualStrings(self.viewControllerA.query[@"k2"], @"v2", @"param error.");
}

代碼16,測試經過URL生成UMViewController的用例

一個測試方法屢次斷言

除了一個操做須要多個測試方法的狀況,在同一個測試方法中也會有對一個結果進行屢次斷言的狀況(上述用例代碼16中已是這種狀況,一下用例更具表明性)。這種狀況發生在操做結果較爲複雜的狀況下,例如生成一個UMNavigationController(代碼17)就是這種狀況:UMNavigationController的初始化方法是帶RootViewController參數的,因此初始化的實例除了判斷其自己是否爲UINavigationController的子類和UMNavigationController實例外,還要判斷rootViewController的合法性,以及viewControllers數組的正確性。

(void)testInitWihtRootViewControllerURL
{
    UMNavigationController *navigator = [[UMNavigationController alloc]
       initWithRootViewControllerURL:[NSURL URLWithString:@"um://viewb"]];

    HC_assertThat(navigator, HC_instanceOf([UINavigationController class]));
    HC_assertThat(navigator, HC_isA([UMNavigationController class]));

    HC_assertThat(navigator.rootViewController, 
                 HC_instanceOf([UMViewController class]));
    HC_assertThat(navigator.rootViewController, HC_isA([ViewControllerB class]));

    HC_assertThatInteger(navigator.viewControllers.count, HC_equalToInteger(1));
    HC_assertThat(navigator.viewControllers,
                  HC_hasInAnyOrder(HC_instanceOf([UMViewController class]), nil));
    HC_assertThat(navigator.viewControllers,
                  HC_hasInAnyOrder(HC_isA([ViewControllerB class]), nil));
    HC_assertThat(navigator.viewControllers,
                  HC_hasInAnyOrder(HC_is(navigator.rootViewController), nil));
}

代碼17,測試生成UMNavigationController的用例

總結

本文一共取了URLManager中的17段代碼片斷做爲例子,介紹了從利用測試框架提供的斷言方法進行簡單的測試,一直到使用自定義匹配引擎規則建立較複雜測試用例,而且提到了部分測試引擎和匹配引擎使用過程當中會遇到的陷阱。旨在推進開發者可以在開發過程當中更簡單高效的使用單元測試,爲提高代碼質量增長一份保障。讀者能夠在URLManager的工程中閱讀更多的測試用例代碼。

相關文章
相關標籤/搜索