用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步华尔兹
- 添加一个失败的testing
- 写出通过的最简单的代码,即使它看起来愚蠢
- 重构(生产代码和testing代码)
你不只是在同一个地方。 你最终:
- 支持dependency injection的良好隔离的代码,
- 简单的代码只能实现已经testing过的代码,
- 针对每种情况的testing(testing本身经过validation),
- 吱吱作响的干净的代码与小,易于阅读的方法。
所有这些好处将比投入TDD的时间节省更多的时间 – 而不仅仅是长期的,而是立即。
有关完整应用程序的示例,请参阅“ testing驱动的iOS开发 ”一书。 这是我对这本书的评论 。