使用OCUnit进行单元测试的示例

39

我真的很难理解单元测试。我确实理解TDD的重要性,但我读到的所有关于单元测试的例子似乎都非常简单和琐碎。例如,测试以确保属性已设置或是否为数组分配了内存。为什么?如果我编码..alloc] init],我真的需要确保它正常工作吗?

我是开发新手,所以我确定我在这里错过了什么,尤其是在TDD周围的狂热。

我认为我的主要问题是找不到任何实际的例子。这里有一个名为setReminderId的方法,看起来是一个很好的测试候选项。使用OCUnit,一个有用的单元测试应该是什么样子的呢?

- (NSNumber *)setReminderId: (NSDictionary *)reminderData
{
    NSNumber *currentReminderId = [[NSUserDefaults standardUserDefaults] objectForKey:@"currentReminderId"];
    if (currentReminderId) {
        // Increment the last reminderId
        currentReminderId = @(currentReminderId.intValue + 1);
    }
    else {
        // Set to 0 if it doesn't already exist
        currentReminderId = @0;
    }
    // Update currentReminderId to model
    [[NSUserDefaults standardUserDefaults] setObject:currentReminderId forKey:@"currentReminderId"];

    return currentReminderId;
}
1个回答

95

更新:我在两个方面改进了这个答案:现在它是一个屏幕录像,并且我从属性注入切换到构造函数注入。请参见如何开始使用Objective-C TDD

棘手的部分在于该方法依赖于外部对象NSUserDefaults。我们不想直接使用NSUserDefaults。相反,我们需要以某种方式注入此依赖项,以便我们可以替换测试用的假用户默认设置。

有几种不同的方法可以做到这一点。一种方法是将其作为额外参数传递给该方法。另一种方法是将其作为类的实例变量。而设置这个ivar的方法也有不同的方式。有“构造函数注入”,其中在初始化器参数中指定它。或者有“属性注入”。对于来自iOS SDK的标准对象,我的偏好是将其作为属性,并带有默认值。

让我们从一个测试开始,属性默认是NSUserDefaults。顺便说一下,我的工具集是Xcode内置的OCUnit,加上OCHamcrest进行断言和OCMockito进行模拟对象。还有其他选择,但这就是我使用的。
第一个测试:用户默认设置
由于没有更好的名称,类将被命名为Example。实例将被命名为“系统测试”(sut)。属性将被命名为userDefaults。下面是ExampleTests.m中的第一个测试,以确定其默认值应该是什么:
#import <SenTestingKit/SenTestingKit.h>

#define HC_SHORTHAND
#import <OCHamcrestIOS/OCHamcrestIOS.h>

@interface ExampleTests : SenTestCase
@end

@implementation ExampleTests

- (void)testDefaultUserDefaultsShouldBeSet
{
    Example *sut = [[Example alloc] init];
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

@end

在这个阶段,这段代码无法编译 - 这被视为测试失败。请仔细检查。如果您能够忽略括号和圆括号,那么测试应该很清晰。
让我们编写最简单的代码来使测试编译并运行 - 但是会失败。这是Example.h:
#import <Foundation/Foundation.h>

@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end

让人敬畏的Example.m文件:

#import "Example.h"

@implementation Example
@end

我们需要在ExampleTests.m的开头添加一行代码。
#import "Example.h"

测试运行失败,提示信息为“期望得到NSUserDefaults实例,但是是nil”。这正是我们想要的。我们已经完成了第一步测试。
第二步是编写最简单的代码来通过该测试。比如这样:
- (id)init
{
    self = [super init];
    if (self)
        _userDefaults = [NSUserDefaults standardUserDefaults];
    return self;
}

通过了!第二步完成。

第三步是重构代码,将所有更改都合并到生产代码和测试代码中。但实际上还没有什么需要清理的。我们完成了第一个测试。到目前为止,我们有什么?一个可以访问NSUserDefaults并在测试中被覆盖的类的开端。

第二个测试:如果没有匹配的关键字,则返回0

现在让我们为该方法编写一个测试。我们想要它做什么?如果用户默认设置没有匹配的关键字,我们希望它返回0。

当您初次开始使用模拟对象时,我建议先手动制作它们,以便您了解它们的用途。然后开始使用模拟对象框架。但我要跳过这一步,使用 OCMockito 使事情更快。我们将以下行添加到 ExampleTest.m 中:

#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>

默认情况下,基于OCMockito的Mock对象将为任何方法返回nil。但是我会编写额外的代码来明确期望,例如说“如果要求objectForKey:@"currentReminderId",则返回nil。”考虑到这一切,我们希望该方法返回NSNumber 0。(我不会传递参数,因为我不知道它的用途。并且我将命名该方法为nextReminderId。)
- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    Example *sut = [[Example alloc] init];
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

这段代码还不能编译。让我们在 Example.h 中定义 nextReminderId 方法:

- (NSNumber *)nextReminderId;

这是Example.m中的第一个实现。我希望测试失败,因此我将返回一个虚假的数字:
- (NSNumber *)nextReminderId
{
    return @-1;
}

测试失败,出现消息“期望值为<0>,但实际值为<-1>”。测试失败很重要,因为这是我们测试测试用例的方式,并确保我们编写的代码将其从失败状态翻转为通过状态。第一步完成。
第二步:让测试通过。但请记住,我们希望最简单的代码能够通过测试。它看起来会非常愚蠢。
- (NSNumber *)nextReminderId
{
    return @0;
}

太棒了,测试通过了!但是我们还没有完成这个测试。现在进入第三步:重构。测试中有重复的代码。让我们把被测系统 sut 提取到一个实例变量中。我们将使用 -setUp 方法进行设置,并使用 -tearDown 方法进行清理(销毁它)。

@interface ExampleTests : SenTestCase
{
    Example *sut;
}
@end

@implementation ExampleTests

- (void)setUp
{
    [super setUp];
    sut = [[Example alloc] init];
}

- (void)tearDown
{
    sut = nil;
    [super tearDown];
}

- (void)testDefaultUserDefaultsShouldBeSet
{
    assertThat([sut userDefaults], is(instanceOf([NSUserDefaults class])));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

@end

We run the tests again, to make sure they still pass, and they do. Refactoring should only be done in "green" or passing state. All tests should continue to pass, whether refactoring is done in the test code or the production code.
Third Test: With no matching key, store 0 in user defaults
Now let's test another requirement: the user defaults should be saved. We'll use the same conditions as the previous test. But we create a new test, instead of adding more assertions to the existing test. Ideally, each test should verify one thing, and have a good name to match.
- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}
verify语句是OCMockito的一种方式,表示“该模拟对象应该以这种方式被调用一次”。我们运行测试并得到一个失败,“期望1个匹配的调用,但收到0个”。第一步完成。
第2步:最简单的通过代码。准备好了吗?开始吧:
- (NSNumber *)nextReminderId
{
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return @0;
}

“你为什么要将@0保存在用户默认设置中,而不是用该值的变量?”你问道。因为这是我们测试的范围之内。稍等片刻,我们会到达那里的。
步骤3:重构。同样,在测试中我们有重复的代码。让我们将mockUserDefaults作为实例变量提取出来。
@interface ExampleTests : SenTestCase
{
    Example *sut;
    NSUserDefaults *mockUserDefaults;
}
@end

测试代码显示警告:“本地声明的'mockUserDefaults'隐藏了实例变量”。修复它们以使用ivar。然后让我们提取一个帮助方法,在每个测试开始时建立用户默认条件。让我们将nil分离出来成为一个单独的变量,以帮助我们进行重构:
    NSNumber *current = nil;
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];

现在选择最后三行,右键点击,选择重构 ▶ 提取。我们将创建一个名为setUpUserDefaultsWithCurrentReminderId:的新方法。
- (void)setUpUserDefaultsWithCurrentReminderId:(NSNumber *)current
{
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
}

现在调用此代码的测试代码如下所示:
    NSNumber *current = nil;
    [self setUpUserDefaultsWithCurrentReminderId:current];

这个变量的唯一目的是帮助我们进行自动重构。让我们把它内联掉:

    [self setUpUserDefaultsWithCurrentReminderId:nil];

测试仍然通过。由于Xcode的自动重构没有将所有这段代码实例替换为新的帮助方法调用,我们需要自己完成。所以现在测试看起来是这样的:

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldReturnZero
{
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    assertThat([sut nextReminderId], is(equalTo(@0)));
}

- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:nil];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}

看到我们是如何不断地清理并进行操作的吗?实际上,测试变得更容易阅读了!

第四个测试:使用匹配的键,返回增加后的值

现在我们想要测试,如果用户默认设置有某个值,我们将返回一个更大的值。我将复制并修改“应该返回零”的测试,使用任意值3。

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
{
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    assertThat([sut nextReminderId], is(equalTo(@4)));
}

测试失败,提示信息为“预期值为<4>,但实际值为<0>”。

以下是通过该测试的简单代码:

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return reminderId;
}

除了那个 setObject:@0,这看起来和你的示例很相似。我还没有发现需要重构的地方。(实际上有需要重构的地方,但我后来才注意到。让我们继续吧。)
第五个测试: 匹配键后,存储递增值
现在我们可以建立另一个测试:在给定相同条件的情况下,它应该将新的提醒 ID 保存在用户默认设置中。通过复制先前的测试、修改它并给它一个好名字,可以很快地完成这个任务。
- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}

测试失败,出现“期望匹配1个调用,但收到0个”的错误。当然,为了让它通过,我们只需将setObject:@0更改为setObject:reminderId。一切都通过了。我们完成了!

等等,我们还没有完成。步骤3:是否有任何需要重构的地方?当我第一次编写这篇文章时,我说:“实际上没有。” 但是在观看Clean Code episode 3之后仔细检查后,我可以听到Uncle Bob告诉我,“一个函数应该有多大?4行可以,也许5行。6行……可以接受。10行太长了。”这个函数有7行,我错过了什么?它必须违反了函数的规则,做了不止一件事。

再说一遍,Bob叔叔:“确保函数只做一件事情的唯一方法就是提取到你无法再提取为止。”这前4行代码共同工作,它们计算出实际值。我们选择它们,然后重构▶提取。遵循 Bob叔叔在第2集中提到的作用域规则,我们将给它一个漂亮、长且有描述性的名称,因为它的使用范围非常有限。下面是自动重构所给出的结果:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        reminderId = @([reminderId integerValue] + 1);
    else
        reminderId = @0;
    return reminderId;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId;
    reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

让我们精简一下,使其更紧凑:

- (NSNumber *)determineNextReminderIdFromUserDefaults
{
    NSNumber *reminderId = [_userDefaults objectForKey:@"currentReminderId"];
    if (reminderId)
        return @([reminderId integerValue] + 1);
    else
        return @0;
}

- (NSNumber *)nextReminderId
{
    NSNumber *reminderId = [self determineNextReminderIdFromUserDefaults];
    [_userDefaults setObject:reminderId forKey:@"currentReminderId"];
    return reminderId;
}

现在每个方法都非常紧凑,任何人都可以阅读主方法的3行代码,了解其功能。但是,我不舒服将该用户默认密钥分散在两个方法中。让我们将其提取到Example.m的头部作为一个常量:
static NSString *const currentReminderIdKey = @"currentReminderId";

我将在生产代码中使用该常量,无论该键在哪里出现。但测试代码仍然使用文字。这可以防止某人意外更改该常量键。
结论
所以这就是它。通过五个测试,我已经按照TDD的方式编写了您要求的代码。希望这能让您更清楚地了解如何进行TDD以及为什么值得这样做。遵循这三步舞:
1. 添加一个失败的测试 2. 编写最简单的代码,即使看起来很愚蠢也要通过 3. 重构(包括生产代码和测试代码)
你不仅会停留在同一个地方。你会得到:
1. 支持依赖注入的良好隔离的代码, 2. 只实现被测试过的最小化代码, 3. 每种情况的测试(测试本身已经验证), 4. 干净整洁的代码,具有小巧易读的方法。
所有这些好处都比投入TDD的时间节省更多的时间,而且不仅仅是长期的,而是立即的。

如果您需要一个完整应用的示例,请阅读书籍《Test-Driven iOS Development》。这里是我的书评


1
很好的写作,Jon。不过似乎模拟NSUserDefaults有点过头了。为什么不直接查询NSUserDefaults呢? - Christopher Pickslay
1
Christopher,我担心在NSUserDefaults中设置值(用于读取)或使用方法写入实际值会干扰手动运行应用程序时的NSUserDefaults。(这有意义吗?如果您不同意,请告诉我。) - Jon Reid
非常棒的帖子,感谢你在iOS上关于TDD的所有工作。我有一个问题,如果我用自己的Singleton类(共享实例)代替NSUserDefaults,这是否被视为依赖注入?如果在setReminderId方法中有其他类(由用户定义的类,例如模型对象或UIViewControler),我是否应该像你为NSUserDefaults做的那样为每个对象创建一个模拟对象? - samir
如果它是你控制的单例,你可以使用另一种称为环境上下文的依赖注入形式:在 setUp 中,让单例用测试控制的虚拟实体替换其真正的实体。然后在 tearDown 中,将真正的实体移回原位。...我可能仍然更喜欢其他形式的依赖注入(特别是构造函数注入),但环境上下文确实成为了一种选择。 - Jon Reid
@JonReid,这是行为验证还是返回值验证? - BangOperator
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接