【问题标题】:Unit Testing Example with OCUnitOCUnit 的单元测试示例
【发布时间】:2012-11-22 14:39:14
【问题描述】:

我真的很难理解单元测试。我确实理解 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;
}

【问题讨论】:

    标签: objective-c ios unit-testing tdd ocunit


    【解决方案1】:

    更新:我在两个方面改进了这个答案:它现在是一个截屏视频,我从属性注入切换到构造函数注入。见How to Get Started with 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”。正是我们想要的。我们已经完成了第一个测试的第 1 步。

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

    - (id)init
    {
        self = [super init];
        if (self)
            _userDefaults = [NSUserDefaults standardUserDefaults];
        return self;
    }
    

    通过了!第 2 步完成。

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

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

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

    当第一次开始使用模拟对象时,我建议首先手工制作它们,以便您了解它们的用途。然后开始使用模拟对象框架。但我将继续前进并使用 OCMockito 让事情变得更快。我们将这些行添加到 ExampleTest.m:

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

    默认情况下,基于 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)));
    }
    

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

    - (NSNumber *)nextReminderId;
    

    这是 Example.m 中的第一个实现。我希望测试失败,所以我将返回一个虚假数字:

    - (NSNumber *)nextReminderId
    {
        return @-1;
    }
    

    测试失败并显示消息“预期为 ,但为 ”。测试失败很重要,因为这是我们测试测试的方式,并确保我们编写的代码将其从失败状态翻转到通过状态。第 1 步完成。

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

    - (NSNumber *)nextReminderId
    {
        return @0;
    }
    

    太棒了,它通过了!但是我们还没有完成这个测试。现在我们来到第 3 步:重构。测试中有重复的代码。让我们将被测系统sut 拉到一个 ivar 中。我们将使用-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
    

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

    第三次测试:没有匹配的键,在用户默认值中存储 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"];
    }
    

    verify 语句是 OCMockito 的说法,“这个模拟对象应该被这样调用一次。”我们运行测试并获得失败,“预期 1 个匹配调用,但收到 0”。第 1 步完成。

    第 2 步:通过的最简单的代码。准备好?如下:

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

    “但是为什么你在用户默认值中保存@0,而不是具有该值的变量?”你问。因为这是我们已经测试过的。等一下,我们会到达那里的。

    第 3 步:重构。同样,我们在测试中有重复的代码。让我们将mockUserDefaults 提取为ivar。

    @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];
    

    现在选择最后 3 行,单击上下文,然后选择 Refactor ▶ Extract。我们将创建一个名为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"];
    }
    

    看看我们是如何边走边打扫的吗?测试实际上变得更容易阅读了!

    第四次测试:匹配key,返回递增值

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

    - (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
    {
        [self setUpUserDefaultsWithCurrentReminderId:@3];
    
        assertThat([sut nextReminderId], is(equalTo(@4)));
    }
    

    根据需要失败:“预期为 ,但为 ”。

    这是通过测试的简单代码:

    - (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。一切都会过去。我们完成了!

    等等,我们还没说完。第三步:有什么要重构的吗?当我第一次写这篇文章时,我说,“不是真的。”但是看了Clean Code episode 3之后再看,我能听到Bob叔叔告诉我,“一个函数应该多大?4行可以,也许5。6是……好吧。10太大了。”那是7行。我错过了什么?做不止一件事一定是违反了功能规则。

    Bob 叔叔再次重申:“真正确定函数做某件事的唯一方法是提取直到你放弃。”前 4 行一起工作;他们计算实际值。让我们选择它们,然后 Refactor ▶ Extract。遵循第 2 集中 Bob 叔叔的范围规则,我们将给它起一个漂亮、长描述性的名称,因为它的使用范围非常有限。以下是自动重构为我们提供的:

    - (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;
    }
    

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

    static NSString *const currentReminderIdKey = @"currentReminderId";
    

    我将在生产代码中出现该键的任何位置使用该常量。但是测试代码继续使用文字。这可以防止我们意外更改该常量键。

    结论

    所以你有它。在五次测试中,我已经对您要求的代码进行了 TDD。希望它能让您更清楚地了解如何进行 TDD 以及为什么值得这样做。跟着华尔兹的三步走

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

    你不只是在同一个地方结束。你最终得到:

    • 支持依赖注入的良好隔离代码,
    • 仅实现已测试内容的极简代码,
    • 针对每种情况进行测试(测试本身已验证),
    • 干净整洁的代码,带有小而易读的方法。

    所有这些好处将比在 TDD 上投入的时间节省更多的时间——不仅是长期的,而且是立竿见影的。

    有关完整应用程序的示例,请参阅Test-Driven iOS Development一书。这是my review of the book。

    【讨论】:

    • 优秀的文章,乔恩。不过,模拟 NSUserDefaults 似乎有点矫枉过正。为什么不直接查询 NSUserDefaults?
    • Christopher,我担心在手动运行应用程序时,在 NSUserDefaults 中设置值(用于读取)或让方法写入实际值会干扰 NSUserDefaults。 (这有意义吗?如果您不同意,请告诉我。)
    • 优秀的帖子,感谢您为 iOS 中的 TDD 所做的所有工作。我有一个问题,如果代替 NSUserDefaults 我有自己的 Singleton 类(共享实例)。这是否被视为依赖注入?如果我在 setReminderId 方法中有其他类(由用户定义的类,例如模型对象或 UIViewControlelr),我应该像您为 NSUserDefaults 所做的那样为每个对象创建一个模拟对象吗?
    • 如果它是您控制的单例,您可以使用另一种形式的依赖注入,称为环境上下文:在setUp 中,让单例将其真正的胆量与测试控件的假胆量放在一边。然后在tearDown 中,将真正的胆量移回原位。 …我可能仍然更喜欢其他形式的依赖注入(尤其是构造函数注入),但环境上下文确实成为一种选择。
    • @JonReid,这是行为验证还是返回值验证。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多