更新:我在两个方面改进了这个答案:它现在是一个截屏视频,我从属性注入切换到构造函数注入。了解如何开始使用 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>
默认情况下,nil
任何方法都会返回一个基于 OCMockito 的模拟对象。但是我会写额外的代码来明确期望,“假设它被要求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)));
}
这还没有编译。nextReminderId
让我们在 Example.h 中定义方法:
- (NSNumber *)nextReminderId;
这是 Example.m 中的第一个实现。我希望测试失败,所以我将返回一个虚假数字:
- (NSNumber *)nextReminderId
{
return @-1;
}
测试失败并显示消息“预期为 <0>,但为 <-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
用户默认值,而不是具有该值的变量?” 你问。因为这是我们已经测试过的。等一下,我们会到达那里的。
第三步:重构。同样,我们在测试中有重复的代码。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)));
}
根据需要,失败:“预期 <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
{
// given
[self setUpUserDefaultsWithCurrentReminderId:@3];
// when
[sut nextReminderId];
// then
[verify(mockUserDefaults) setObject:@4 forKey:@"currentReminderId"];
}
该测试失败,“预期 1 个匹配调用,但收到 0”。当然,要让它通过,我们只需将 更改setObject:@0
为setObject:reminderId
. 一切都会过去。我们完成了!
等等,我们还没有完成。第三步:有什么要重构的吗?当我第一次写这篇文章时,我说,“不是真的。” 但是看完Clean Code 第 3 集之后再看一遍,我可以听到 Bob 叔叔告诉我,“函数应该有多大?4 行就可以了,也许 5 行。6 行……可以。10 太大了。” 那是7行。我错过了什么?做不止一件事一定是违反了功能规则。
再次,鲍勃叔叔:“真正确定一个函数做一件事的唯一方法是提取直到你放弃。” 前 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 以及为什么值得这样做。跟随 3 步华尔兹
- 添加一项失败的测试
- 编写通过的最简单的代码,即使它看起来很愚蠢
- 重构(生产代码和测试代码)
你不只是在同一个地方结束。你最终得到:
- 支持依赖注入的隔离良好的代码,
- 仅实现已测试内容的极简代码,
- 每种情况的测试(测试本身已验证),
- 干净整洁的代码,小而易读的方法。
所有这些好处将比在 TDD 上投入的时间节省更多的时间——不仅是长期的,而且是立竿见影的。
有关涉及完整应用程序的示例,请获取Test-Driven iOS Development一书。这是我对这本书的评论。