更新:我在两个方面改进了这个答案:现在它是一个屏幕录像,并且我从属性注入切换到构造函数注入。请参见如何开始使用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的开头添加一行代码。
测试运行失败,提示信息为“期望得到NSUserDefaults实例,但是是nil”。这正是我们想要的。我们已经完成了第一步测试。
第二步是编写最简单的代码来通过该测试。比如这样:
- (id)init
{
self = [super init];
if (self)
_userDefaults = [NSUserDefaults standardUserDefaults];
return self;
}
通过了!第二步完成。
第三步是重构代码,将所有更改都合并到生产代码和测试代码中。但实际上还没有什么需要清理的。我们完成了第一个测试。到目前为止,我们有什么?一个可以访问NSUserDefaults
并在测试中被覆盖的类的开端。
第二个测试:如果没有匹配的关键字,则返回0
现在让我们为该方法编写一个测试。我们想要它做什么?如果用户默认设置没有匹配的关键字,我们希望它返回0。
当您初次开始使用模拟对象时,我建议先手动制作它们,以便您了解它们的用途。然后开始使用模拟对象框架。但我要跳过这一步,使用 OCMockito 使事情更快。我们将以下行添加到 ExampleTest.m 中:
默认情况下,基于OCMockito的Mock对象将为任何方法返回
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;
}
测试失败,出现消息“期望值为<0>,但实际值为<-1>”。测试失败很重要,因为这是我们测试测试用例的方式,并确保我们编写的代码将其从失败状态翻转为通过状态。第一步完成。
第二步:让测试通过。但请记住,我们希望最简单的代码能够通过测试。它看起来会非常愚蠢。
- (NSNumber *)nextReminderId
{
return @0;
}
太棒了,测试通过了!但是我们还没有完成这个测试。现在进入第三步:重构。测试中有重复的代码。让我们把被测系统 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
We run the tests again, to make sure they still pass, and they do. Refactoring should only be done in "green" or passing state. All tests should continue to pass, whether refactoring is done in the test code or the production code.
Third Test: With no matching key, store 0 in user defaults
Now let's test another requirement: the user defaults should be saved. We'll use the same conditions as the previous test. But we create a new test, instead of adding more assertions to the existing test. Ideally, each test should verify one thing, and have a good name to match.
- (void)testNextReminderIdWithNoCurrentReminderIdInUserDefaultsShouldSaveZeroInUserDefaults
{
NSUserDefaults *mockUserDefaults = mock([NSUserDefaults class]);
[sut setUserDefaults:mockUserDefaults];
[given([mockUserDefaults objectForKey:@"currentReminderId"]) willReturn:nil];
[sut nextReminderId];
[verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}
verify
语句是OCMockito的一种方式,表示“该模拟对象应该以这种方式被调用一次”。我们运行测试并得到一个失败,“期望1个匹配的调用,但收到0个”。第一步完成。
第2步:最简单的通过代码。准备好了吗?开始吧:
- (NSNumber *)nextReminderId
{
[_userDefaults setObject:@0 forKey:@"currentReminderId"];
return @0;
}
“你为什么要将
@0
保存在用户默认设置中,而不是用该值的变量?”你问道。因为这是我们测试的范围之内。稍等片刻,我们会到达那里的。
步骤3:重构。同样,在测试中我们有重复的代码。让我们将
mockUserDefaults
作为实例变量提取出来。
@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];
现在选择最后三行,右键点击,选择重构 ▶ 提取。我们将创建一个名为
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
{
[self setUpUserDefaultsWithCurrentReminderId:nil];
[sut nextReminderId];
[verify(mockUserDefaults) setObject:@0 forKey:@"currentReminderId"];
}
看到我们是如何不断地清理并进行操作的吗?实际上,测试变得更容易阅读了!
第四个测试:使用匹配的键,返回增加后的值
现在我们想要测试,如果用户默认设置有某个值,我们将返回一个更大的值。我将复制并修改“应该返回零”的测试,使用任意值3。
- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldReturnOneGreater
{
[self setUpUserDefaultsWithCurrentReminderId:@3];
assertThat([sut nextReminderId], is(equalTo(@4)));
}
测试失败,提示信息为“预期值为<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;
}
除了那个
setObject:@0
,这看起来和你的示例很相似。我还没有发现需要重构的地方。(实际上有需要重构的地方,但我后来才注意到。让我们继续吧。)
第五个测试: 匹配键后,存储递增值
现在我们可以建立另一个测试:在给定相同条件的情况下,它应该将新的提醒 ID 保存在用户默认设置中。通过复制先前的测试、修改它并给它一个好名字,可以很快地完成这个任务。
- (void)testNextReminderIdWithCurrentReminderIdInUserDefaultsShouldSaveOneGreaterInUserDefaults
{
[self setUpUserDefaultsWithCurrentReminderId:@3];
[sut nextReminderId];
[verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}
测试失败,出现“期望匹配1个调用,但收到0个”的错误。当然,为了让它通过,我们只需将setObject:@0
更改为setObject:reminderId
。一切都通过了。我们完成了!
等等,我们还没有完成。步骤3:是否有任何需要重构的地方?当我第一次编写这篇文章时,我说:“实际上没有。” 但是在观看Clean Code episode 3之后仔细检查后,我可以听到Uncle Bob告诉我,“一个函数应该有多大?4行可以,也许5行。6行……可以接受。10行太长了。”这个函数有7行,我错过了什么?它必须违反了函数的规则,做了不止一件事。
再说一遍,Bob叔叔:“确保函数只做一件事情的唯一方法就是提取到你无法再提取为止。”这前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;
}
让我们精简一下,使其更紧凑:
- (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行代码,了解其功能。但是,我不舒服将该用户默认密钥分散在两个方法中。让我们将其提取到Example.m的头部作为一个常量:
static NSString *const currentReminderIdKey = @"currentReminderId";
我将在生产代码中使用该常量,无论该键在哪里出现。但测试代码仍然使用文字。这可以防止某人意外更改该常量键。
结论
所以这就是它。通过五个测试,我已经按照TDD的方式编写了您要求的代码。希望这能让您更清楚地了解如何进行TDD以及为什么值得这样做。遵循这三步舞:
1. 添加一个失败的测试
2. 编写最简单的代码,即使看起来很愚蠢也要通过
3. 重构(包括生产代码和测试代码)
你不仅会停留在同一个地方。你会得到:
1. 支持依赖注入的良好隔离的代码,
2. 只实现被测试过的最小化代码,
3. 每种情况的测试(测试本身已经验证),
4. 干净整洁的代码,具有小巧易读的方法。
所有这些好处都比投入TDD的时间节省更多的时间,而且不仅仅是长期的,而是立即的。
如果您需要一个完整应用的示例,请阅读书籍《Test-Driven iOS Development》。这里是我的书评。
setUp
中,让单例用测试控制的虚拟实体替换其真正的实体。然后在tearDown
中,将真正的实体移回原位。...我可能仍然更喜欢其他形式的依赖注入(特别是构造函数注入),但环境上下文确实成为了一种选择。 - Jon Reid