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

gaosboy發表於2013-08-29

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

準備框架,定義基類

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

定義測試用例基類: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)。

程式碼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的工程中閱讀更多的測試用例程式碼。

相關文章