使用OCUnit的单元测试示例

mno*_*rt9 39 tdd unit-testing objective-c ocunit ios

我真的很难理解单元测试.我确实理解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;
}
Run Code Online (Sandbox Code Playgroud)

Jon*_*eid 95

更新:我已经通过两种方式改进了这个答案:它现在是一个截屏视频,我从属性注入切换到构造函数注入.请参阅如何开始使用Objective-C TDD

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

有几种不同的方法可以做到这一点.一种是将其作为方法的额外参数传递.另一种方法是使它成为类的实例变量.设置这种伊娃有不同的方法.有"构造函数注入",它在初始化程序参数中指定.或者是"财产注入".对于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
Run Code Online (Sandbox Code Playgroud)

在这个阶段,这不会编译 - 这将被视为测试失败.仔细看看.如果你能让你的眼睛跳过括号和括号,测试应该非常清楚.

让我们编写最简单的代码来获得编译和运行的测试 - 并且失败.这是Example.h:

#import <Foundation/Foundation.h>

@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end
Run Code Online (Sandbox Code Playgroud)

而令人敬畏的例子:

#import "Example.h"

@implementation Example
@end
Run Code Online (Sandbox Code Playgroud)

我们需要在ExampleTests.m的最开头添加一行:

#import "Example.h"
Run Code Online (Sandbox Code Playgroud)

测试运行,并且失败并显示消息"预期NSUserDefaults的实例,但是为零".正是我们想要的.我们已经完成了第一次测试的第一步.

第2步是编写我们可以通过该测试的最简单的代码.这个怎么样:

- (id)init
{
    self = [super init];
    if (self)
        _userDefaults = [NSUserDefaults standardUserDefaults];
    return self;
}
Run Code Online (Sandbox Code Playgroud)

它过去了!第2步完成.

第3步是重构代码以包含生产代码和测试代码中的所有更改.但是还没有什么可以清理的.我们完成了第一次测试.到目前为止我们有什么?可以访问的类的开头NSUserDefaults,但也可以覆盖它以进行测试.

第二次测试:没有匹配的键,返回0

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

当我第一次使用模拟对象时,我建议首先手工制作它们,这样你就可以了解它们的用途.然后开始使用模拟对象框架.但是我要继续前进并使用OCMockito来加快速度.我们将这些行添加到ExampleTest.m:

#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>
Run Code Online (Sandbox Code Playgroud)

默认情况下,基于OCMockito的模拟对象将返回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)));
}
Run Code Online (Sandbox Code Playgroud)

这还没有编译.让我们nextReminderId在Example.h中定义方法:

- (NSNumber *)nextReminderId;
Run Code Online (Sandbox Code Playgroud)

这是Example.m中的第一个实现.我希望测试失败,所以我要返回一个伪造的数字:

- (NSNumber *)nextReminderId
{
    return @-1;
}
Run Code Online (Sandbox Code Playgroud)

测试失败,并显示消息"预期<0>,但是<-1>".测试失败很重要,因为它是我们测试测试的方式,并确保我们编写的代码将其从失败状态转换为传递状态.第1步完成.

第2步:让我们通过测试测试.但请记住,我们想要通过测试的最简单的代码.它看起来非常愚蠢.

- (NSNumber *)nextReminderId
{
    return @0;
}
Run Code Online (Sandbox Code Playgroud)

太棒了,它过去了!但我们还没有完成这项测试.现在我们来到第3步:重构.测试中有重复的代码.让我们把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
Run Code Online (Sandbox Code Playgroud)

我们再次运行测试,以确保它们仍然通过,他们确实通过了.重构只能在"绿色"或通过状态下进行.无论是在测试代码还是生产代码中进行重构,所有测试都应继续通过.

第三次测试:如果没有匹配的密钥,则将0存储在用户默认值中

现在让我们测试另一个要求:应该保存用户默认值.我们将使用与先前测试相同的条件.但是我们创建了一个新测试,而不是在现有测试中添加更多断言.理想情况下,每个测试都应验证一件事,并且要有一个好的名称来匹配.

- (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"];
}
Run Code Online (Sandbox Code Playgroud)

verify声明是OCMockito的说法,"这个模拟对象应该这样被称为一次." 我们运行测试并失败,"预期1匹配调用,但收到0".第1步完成.

第2步:通过的最简单的代码.准备?开始:

- (NSNumber *)nextReminderId
{
    [_userDefaults setObject:@0 forKey:@"currentReminderId"];
    return @0;
}
Run Code Online (Sandbox Code Playgroud)

"但是为什么要保存@0用户默认值,而不是那个带有该值的变量?" 你问.因为这是我们测试过的.坚持下去,我们会到达那里.

第3步:重构.同样,我们在测试中有重复的代码.让我们mockUserDefaults像伊娃一样拉出来.

@interface ExampleTests : SenTestCase
{
    Example *sut;
    NSUserDefaults *mockUserDefaults;
}
@end
Run Code Online (Sandbox Code Playgroud)

测试代码显示警告,"mockUserDefaults'的本地声明隐藏了实例变量".修复它们以使用ivar.然后让我们提取一个辅助方法,以在每个测试开始时建立用户默认值的条件.让我们把它nil拉出一个单独的变量来帮助我们进行重构:

    NSNumber *current = nil;
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
Run Code Online (Sandbox Code Playgroud)

现在选择最后3行,上下文单击,然后选择Refactor▶Extract.我们将创建一个名为的新方法setUpUserDefaultsWithCurrentReminderId:

- (void)setUpUserDefaultsWithCurrentReminderId:(NSNumber *)current
{
    mockUserDefaults = mock([NSUserDefaults class]);
    [sut setUserDefaults:mockUserDefaults];
    [given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
}
Run Code Online (Sandbox Code Playgroud)

现在调用它的测试代码如下所示:

    NSNumber *current = nil;
    [self setUpUserDefaultsWithCurrentReminderId:current];
Run Code Online (Sandbox Code Playgroud)

这个变量的唯一原因是帮助我们进行自动重构.让我们把它拼凑起来:

    [self setUpUserDefaultsWithCurrentReminderId:nil];
Run Code Online (Sandbox Code Playgroud)

测试仍然通过.由于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"];
}
Run Code Online (Sandbox Code Playgroud)

看看我们如何继续清洁?测试实际上变得更容易阅读!

第四次测试:使用匹配键,返回递增值

现在我们要测试一下,如果用户默认值有一些值,我们返回一个更大的值.我将复制并更改"应该返回零"测试,使用任意值3.

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

    assertThat([sut nextReminderId], is(equalTo(@4)));
}
Run Code Online (Sandbox Code Playgroud)

根据需要失败:"预期<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;
}
Run Code Online (Sandbox Code Playgroud)

除此之外setObject:@0,这开始看起来像你的例子.我还没有看到任何重构的东西.(实际上有,但直到后来才注意到.让我们继续.)

第五次测试:使用匹配键,存储递增值

现在我们可以再建一个测试:给定相同的条件,它应该在用户默认值中保存新的提醒ID.这可以通过复制早期测试,更改它并给它一个好名字来快速完成:

- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
    // given
    [self setUpUserDefaultsWithCurrentReminderId:@3];

    // when
    [sut nextReminderId];

    // then
    [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}
Run Code Online (Sandbox Code Playgroud)

该测试失败,"预期1匹配调用,但收到0".当然,为了让它通过,我们只需将其更改setObject:@0setObject:reminderId.一切都过去了.我们完成了!

等等,我们还没完成.第3步:有什么可以重构的吗?当我第一次写这篇文章时,我说,"不是真的." 但看完" 清洁密码"第3集后,我可以听到鲍勃叔叔告诉我,"一个功能应该有多大?4条线路可以,也许是5条线路......好吧.10条太大了." 这是7行.我错过了什么?它必须通过做一件以上的事情来违反职能规则.

再一次,鲍勃叔叔:"真正确定一个功能做一件事的唯一方法就是提取'直到你掉落'." 前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;
}
Run Code Online (Sandbox Code Playgroud)

让我们把它清理干净吧:

- (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;
}
Run Code Online (Sandbox Code Playgroud)

现在每种方法都非常紧凑,任何人都可以轻松阅读主要方法的3行,看看它的作用.但是,如果用户默认密钥分布在两种方法中,我会感到很不舒服.让我们将它提取到Example.m头部的常量:

static NSString *const currentReminderIdKey = @"currentReminderId";
Run Code Online (Sandbox Code Playgroud)

我将在生产代码中出现该密钥的任何地方使用该常量.但测试代码继续使用文字.这可以防止我们意外地改变那个恒定键.

结论

所以你有它.在五个测试中,我已经使用TDD来查找您要求的代码.希望它能让您更清楚地了解如何进行TDD,以及为什么它值得.按照3步华尔兹

  1. 添加一个失败的测试
  2. 编写通过的最简单的代码,即使它看起来很愚蠢
  3. 重构(生产代码和测试代码)

你不只是在同一个地方.你最终得到:

  • 支持依赖注入的完全隔离的代码,
  • 极简主义代码,只实现已测试的内容,
  • 每个案例的测试(测试本身已经过验证),
  • 带有易于阅读的小巧方法,代码干净利落.

所有这些好处将比投入TDD的时间节省更多时间 - 而不仅仅是长期,而是立即.

有关完整应用程序的示例,请阅读" 测试驱动的iOS开发 "一书.这是我对这本书评论.