iOS 开发中的单元测试(三)——URLManager 中的测试用例解析

阅读数:3077 2013 年 8 月 27 日

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)。

// 普通字符串,带有字母和数字 
@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;

代码 2,定义属性

(void)setUpClass
{
    self.string                 = @"NSString For Test with a number 8848.";
    self.stringWithoutNumber    = @"NSString For Test.";
    self.toBeEncode             = @"~!@#$%^&*()_+=-[]{}:;\"'<>.,/?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&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:@"

um://viewa2"]; NSMutableDictionary *config = [UMNavigationController config]; NSLog(@"%@", [config allKeys]); HC_assertThat([config allKeys], HC_containsInAnyOrder(HC_equalTo(@"um://viewa2"), HC_equalTo(@"

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>)

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>)

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

(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://

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.

view.frame.origin.y)));

代码 13,UMView 测试用例片段

自定义匹配规则的详细说明可以参见上一篇《iOS 开发中的单元测试(二)》,本文只对开发自定义规则中遇到的问题和需要特殊处理的方面进行解释。

OCHamcrest 的匹配规则要求被匹配的必须是一个有强引用的对象,所以当被匹配的是一个 struct 结构(如 CGPoint)需要进行一次转换,如代码 14 中定义的这个规则扩展——OBJC_EXPORT id<HCMatcher> HC_equalToPoint(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 && 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?

p1=v1&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

(@"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

(@"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&p2=v2"] withQuery:@{@"k1":@"v1", @"k2":@"v2"}]; HC_assertThat([self.viewControllerA.params allKeys], HC_containsInAnyOrder

(@"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

(@"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 的工程中阅读更多的测试用例代码。

此前预告单元测试系列文章共 3 篇,由于此前的 WWDC 2013 上新版本的 XCode 在单元测试方面做了比较大的更新,所以我会在两周内再为大家介绍一下有关新版本 XCode 的单元测试和持续集成的相关内容,作为单元测试系列文章的番外篇,谢谢大家。


感谢李永伦对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论