用OCUnit进行unit testing的例子

我真的很难理解unit testing。 我明白TDD的重要性,但是我所读到的所有unit testing的例子都是非常简单和微不足道的。 例如,testing以确保属性已设置或内存分配给数组。 为什么? 如果我编码出来了..alloc] init] ,我真的需要确保它的工作?

我是新来的开发人员,所以我相信我在这里错过了一些东西,特别是围绕着TDD的所有热潮。

我认为我的主要问题是我找不到任何实际的例子。 这里是一个setReminderId方法,似乎是一个很好的候选人testing。 什么有用的unit testing看起来像确保这是工作? (使用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 TDD

棘手的部分是该方法依赖于外部对象NSUserDefaults。 我们不想直接使用NSUserDefaults。 相反,我们需要以某种方式注入这个依赖项,以便我们可以用一个假的用户默认值来替代testing。

有几种不同的方法来做到这一点。 一个是通过将其作为该方法的额外论据。 另一个是使其成为该类的实例variables。 build立这个伊娃有不同的方式。 有“构造函数注入”它在初始化参数中指定。 或者有“注资”。 对于来自iOS SDK的标准对象,我的首选是使其成为一个属性,具有默认值。

所以让我们开始一个testing,默认情况下,属性是NSUserDefaults。 顺便说一下,我的工具集是Xcode的内置OCUnit,加上用于断言的OCHamcrest和用于模拟对象的OCMockito 。 还有其他的select,但这就是我使用的。

第一次testing:用户默认

缺less一个更好的名字,这个类将被命名为Example 。 该实例将被命名为“待测系统”。 该属性将被命名为userDefaults 。 下面是第一个testing,在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 

在这个阶段,这不会被编译 – 这被视为testing失败。 看一下。 如果你可以跳过括号和括号,testing应该很清楚。

让我们编写最简单的代码,以便让testing编译并运行 – 并且失败。 这里是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" 

testing运行,失败并显示消息“预期NSUserDefaults的一个实例,但是没有”。 正是我们想要的。 我们已经达到了第一个testing的第一步。

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

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

它通过! 步骤2完成。

第3步是重构代码,将所有更改合并到生产代码和testing代码中。 但是还没有什么可以清理的。 我们已经完成了我们的第一个testing。 到目前为止我们有什么? 一个可以访问NSUserDefaults的类的开始,但也可以重写它来进行testing。

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

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

首先从模拟对象开始,我build议首先用手工制作它们,以便您了解它们的用途。 然后开始使用模拟对象框架。 但是我要先跳起来,使用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中的第一个实现。 我想testing失败,所以我要返回一个假的数字:

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

testing失败,消息“Expected <0>,但是<-1>”。 testing失败是很重要的,因为这是我们testingtesting的方式,并确保我们编写的代码将其从失败状态翻转为通过状态。 第一步完成。

第二步:让我们通过testingtesting。 但请记住,我们需要通过testing的最简单的代码。 这将是非常愚蠢的。

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

令人惊叹的,它通过! 但是我们还没有完成这个testing。 现在我们来到步骤3:重构。 testing中有重复的代码。 让我们把testing的系统拉进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 

我们再次运行testing,以确保他们仍然通过,他们这样做。 重构只能在“绿色”或通过状态下进行。 所有testing都应该继续通过,无论是在testing代码还是生产代码中进行重构。

第三个testing:没有匹配的密钥,存储0用户默认值

现在让我们来testing另一个要求:应该保存用户的默认值。 我们将使用与以前的testing相同的条件。 但是我们创build一个新的testing,而不是增加更多的断言到现有的testing。 理想情况下,每个testing应该validation一件事,并有一个好名字匹配。

 - (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的一种说法,“这个模拟对象本来就是这样调用的。” 我们运行testing,并得到一个失败,“预期1匹配的调用,但收到0”。 第一步完成。

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

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

“但是,为什么你在用户默认值中保存@0 ,而不是具有该值的variables? 你问。 因为那就是我们testing过的。 等一下,我们会到达那里。

第3步:重构。 再次,我们在testing中有重复的代码。 让我们看看mockUserDefaults作为一个伊娃。

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

testing代码显示警告,“本地声明'mockUserDefaults'隐藏实例variables”。 修复他们使用伊娃。 然后,我们提取一个帮助器方法来build立每个testing开始时用户默认的条件。 让我们把这个nil放到一个单独的variables上来帮助我们进行重构:

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

现在select最后3行,点击上下文,然后selectRefactor▶Extract。 我们将创build一个名为setUpUserDefaultsWithCurrentReminderId:的新方法setUpUserDefaultsWithCurrentReminderId:

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

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

  NSNumber *current = nil; [self setUpUserDefaultsWithCurrentReminderId:current]; 

这个variables的唯一原因是帮助我们进行自动化重构。 让我们把它排除在外:

  [self setUpUserDefaultsWithCurrentReminderId:nil]; 

testing仍然通过。 由于Xcode的自动重构并不是通过调用新的帮助器方法来replace该代码的所有实例,所以我们需要自己去做。 所以现在testing看起来像这样:

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

看看我们如何不断清理,我们走了吗? testing实际上变得更容易阅读!

第四次testing:使用匹配键,返回增加的值

现在我们要testing一下,如果用户默认有一些值,我们会返回一个更大的值。 我要复制并更改“应该返回零”testing,使用任意值3。

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

根据需要,失败:“期望<4>,但是<0>”。

以下是通过testing的简单代码:

 - (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 ,这开始看起来像你的例子。 我没有看到任何重构,但。 (实际上是,但是直到后来我才注意到,让我们继续吧。)

第五个testing:使用匹配键,存储递增值

现在我们可以build立一个更多的testing:给定相同的条件,它应该保存新的提醒ID在用户的默认值。 这很快就通过复制早期的testing,改变它,并给它一个好名字:

 - (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults { // given [self setUpUserDefaultsWithCurrentReminderId:@3]; // when [sut nextReminderId]; // then [verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"]; } 

该testing失败,“期望1匹配的调用,但收到0”。 为了传递它,当然,我们只需将setObject:@0更改为setObject:reminderId 。 一切都过去了 我们完成了!

等等,我们没有完成。 第三步:有什么要重构吗? 当我第一次写这个时,我说:“不是真的。” 但是看清洁码第3集看后,我可以听到Bob叔叔告诉我:“function应该有多大?4行OK,也许5.6是…好吧,10是太大了。 那是7条线。 我错过了什么? 它不止一件事就是违背职能的规则。

同样,鲍勃叔叔:“确保一个function做一件事的唯一方法就是提取”直到你放弃“。 前4条线共同工作; 他们计算实际价值。 让我们select它们,并重构▶提取。 根据第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; } 

现在每种方法都非常紧密,任何人都可以轻松阅读主要方法的三行来查看它的function。 但是让我感到不舒服的是,这个用户默认使用两种方法分配密钥。 我们把它解压缩成Example.m头部的一个常量:

 static NSString *const currentReminderIdKey = @"currentReminderId"; 

无论密钥出现在生产代码中的哪个位置,我都会使用这个常量。 但是testing代码继续使用文字。 这从一个偶然地改变那个恒定的钥匙的人保护我们。

结论

所以你有它。 在五个testing中,我已经TDD'd我的方式,你要求的代码。 希望它能让你更清楚地了解如何使用TDD,以及为什么它值得。 遵循3步华尔兹

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

你不只是在同一个地方。 你最终:

  • 支持dependency injection的良好隔离的代码,
  • 简单的代码只能实现已经testing过的代码,
  • 针对每种情况的testing(testing本身经过validation),
  • 吱吱作响的干净的代码与小,易于阅读的方法。

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

有关完整应用程序的示例,请参阅“ testing驱动的iOS开发 ”一书。 这是我对这本书的评论 。