Update: I've improved on this answer in two ways: it's now a screencast, and I switched from property injection to constructor injection. See 100
棘手的是,该方法依赖于外部对象NSUserDefaults.我们不想直接使用NSUserDefaults.相反,我们需要以某种方式注入这种依赖性,这样我们就可以用虚假的用户默认值来代替测试.
有几种不同的方法可以做到这一点.一种是将其作为额外参数传递给该方法.另一个是使其成为类的实例变量.建立这个ivar有不同的方法.在初始化器参数中指定了"构造函数注入".或者有"财产注入"对于iOS SDK中的标准对象,我的首选是使用默认值将其设置为属性.
因此,让我们从一个测试开始,该属性在默认情况下是NSUserDefaults.顺便说一句,我的工具集是Xcode的内置OCUnit,另外OCHamcrest个用于断言,OCMockito个用于模拟对象.还有其他 Select ,但这就是我所用的.
第一个测试:用户默认值
由于没有更好的名称,该类将被命名为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
在这个阶段,它不会编译——这将被视为测试失败.仔细看看.如果你能让你的眼睛跳过括号和圆括号,测试应该非常清晰.
让我们编写最简单的代码来编译和运行该测试,结果失败.下面是一个例子.h:
#import <Foundation/Foundation.h>
@interface Example : NSObject
@property (strong, nonatomic) NSUserDefaults *userDefaults;
@end
以及令人敬畏的例子.m:
#import "Example.h"
@implementation Example
@end
我们需要在ExampleTests的开头添加一行.m:
#import "Example.h"
测试运行时失败,并显示消息"应为NSUserDefaults的一个实例,但为零".这正是我们想要的.我们已经完成了第一次测试的第一步.
第二步是编写最简单的代码来通过测试.这个怎么样:
- (id)init
{
self = [super init];
if (self)
_userDefaults = [NSUserDefaults standardUserDefaults];
return self;
}
它过go 了!第二步完成了.
第3步是重构代码,将所有更改都包含在生产代码和测试代码中.但实际上还没有什么需要清理的.我们完成了第一次测试.到目前为止我们有什么?一个可以访问NSUserDefaults
的类的开头,但也可以覆盖它进行测试.
第二个测试:没有匹配的键,返回0
现在,让我们为该方法编写一个测试.我们希望它做什么?如果用户默认没有匹配的密钥,我们希望它返回0.
当第一次开始模拟对象时,我建议首先手工制作,这样你就知道它们的用途了.然后开始使用模拟对象框架.但我要跳到前面,用Ocmocito让事情变得更快.我们将这些行添加到ExampleTest中.m:
#define MOCKITO_SHORTHAND
#import <OCMockitoIOS/OCMockitoIOS.h>
默认情况下,基于OCMockito的mock对象对于任何方法都将返回nil
.但我会编写额外的代码,通过这样的方式来明确预期:"如果要求它输入objectForKey:@"currentReminderId"
,那么它将返回nil
."考虑到所有这些,我们希望该方法返回NS0号.(我不会通过一个论点,因为我不知道它的用途.我将命名方法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)));
}
这还没有编译.让我们在示例中定义nextReminderId
方法.h:
- (NSNumber *)nextReminderId;
下面是示例中的第一个实现.m、 我希望测试失败,所以我要返回一个假数字:
- (NSNumber *)nextReminderId
{
return @-1;
}
The test fails with the message, "Expected <0>, but was <-1>". It's important that the test fail, because it's our way of testing the test, and ensuring that the code we write flips it from a failing state to a passing state. Step 1 is complete.
第二步:让我们通过测试.但请记住,我们需要通过测试的最简单代码.这看起来会非常愚蠢.
- (NSNumber *)nextReminderId
{
return @0;
}
太棒了,它过go 了!但我们还没有完成这个测试.现在我们进入第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次".第一步完成了.
第2步:传递的最简单代码.准备好的下面是:
- (NSNumber *)nextReminderId
{
[_userDefaults setObject:@0 forKey:@"currentReminderId"];
return @0;
}
"但为什么要在用户默认值中保存@0
,而不是保存一个具有该值的变量?"你问.因为这是我们测试的范围.等一下,我们会到的.
第三步:重构.同样,我们在测试中有重复的代码.让我们拿出mockUserDefaults
美元作为ivar.
@interface ExampleTests : SenTestCase
{
Example *sut;
NSUserDefaults *mockUserDefaults;
}
@end
测试代码显示警告,"mockUserDefaults的本地声明隐藏了实例变量".让他们使用ivar.然后,让我们提取一个helper方法,以在每个测试开始时建立用户默认值的条件.让我们把这nil
个变量拉到一个单独的变量中,以帮助我们进行重构:
NSNumber *current = nil;
mockUserDefaults = mock([NSUserDefaults class]);
[sut setUserDefaults:mockUserDefaults];
[given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:current];
现在 Select 最后3行,单击上下文,然后 Select 重构▶ 摘录我们将创建一个名为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的自动重构并没有用调用新的helper方法来替换该代码的所有实例,所以我们需要自己做这件事.现在的测试是这样的:
- (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)));
}
That fails, as desired: "Expected <4>, but was <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
.一切都过go 了.我们完了!
等等,我们还没完.第三步:有什么需要重构的吗?当我第一次写这篇文章时,我说,"不太可能.""鲍勃叔叔告诉我,5行就够了,但我能听到10行就够了."鲍勃叔叔说一共7行.我错过了什么?它做了不止一件事,肯定违反了函数法则.
同样,Bob叔叔:"要真正确保一个函数只做一件事,唯一的方法就是提取'直到你放弃'."前4行一起工作;他们计算实际值.让我们 Select 它们,然后重构▶ 摘录根据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;
}
现在,每种方法都非常紧凑,任何人都可以很容易地阅读主方法的3行内容,了解它的作用.但我不喜欢将用户默认值密钥分散在两种方法中.让我们将其提取为示例开头的常数.m:
static NSString *const currentReminderIdKey = @"currentReminderId";
无论生产代码中出现哪个键,我都会使用这个常量.但是测试代码继续使用文本.这样可以防止有人不小心更改了恒定的键.
结论
好了.在五次测试中,我找到了你要的代码.希望它能让你更清楚地了解如何进行TDD,以及为什么值得这么做.跟着三步华尔兹
- 添加一个失败的测试
- 编写最简单的代码,即使看起来很愚蠢
- 重构(生产代码和测试代码)
你不只是在同一个地方结束.你最终会得到:
- 支持依赖注入的隔离良好的代码,
- 只实现已测试内容的极简代码,
- 每种情况下的测试(测试本身经过验证),
- 代码简洁明了,方法简单易读.
所有这些好处将比投资于TDD的时间节省更多的时间——不仅是长期的,而且是立即的.
举一个涉及完整应用程序的例子,获取Test-Driven iOS Development本书.这是my review of the book美元.