我真的很难理解单元测试。我确实理解 TDD 的重要性,但是我读到的所有单元测试示例似乎都非常简单和琐碎。例如,测试以确保设置了属性或是否为数组分配了内存。为什么?如果我编码出 ..alloc] init]
,我真的需要确保它有效吗?
我是开发新手,所以我确定我在这里遗漏了一些东西,尤其是围绕 TDD 的所有热潮。
我认为我的主要问题是我找不到任何实际例子。这是一个方法 setReminderId
,它似乎是一个很好的测试候选者。一个有用的单元测试应该是什么样子来确保它正常工作? (使用 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;
}
最佳答案
更新:我在两个方面改进了这个答案:它现在是一个截屏视频,我从属性注入(inject)切换到构造函数注入(inject)。见 How to Get Started with Objective-C TDD
棘手的部分是该方法依赖于外部对象 NSUserDefaults。我们不想直接使用 NSUserDefaults。相反,我们需要以某种方式注入(inject)此依赖项,以便我们可以替换假用户默认值进行测试。
有几种不同的方法可以做到这一点。一种是将它作为额外的参数传递给方法。另一种方法是使其成为类的实例变量。并且有不同的方法来设置这个 ivar。在初始化参数中指定了“构造函数注入(inject)”。或者有“属性(property)注入(inject)”。对于来自 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 的实例,但为零”。正是我们想要的。我们已经到了第一次测试的第 1 步。
第 2 步是编写我们可以通过该测试的最简单的代码。这个怎么样:
- (id)init
{
self = [super init];
if (self)
_userDefaults = [NSUserDefaults standardUserDefaults];
return self;
}
它通过了!步骤 2 完成。
第 3 步是重构代码以包含生产代码和测试代码中的所有更改。但是真的没有什么可以清理的。我们完成了我们的第一个测试。到目前为止,我们有什么?可以访问
NSUserDefaults
的类的开头,但也可以覆盖它以进行测试。第二次测试:没有匹配的键,返回 0
现在让我们为该方法编写一个测试。我们要它做什么?如果用户默认没有匹配的键,我们希望它返回 0。
当第一次开始使用模拟对象时,我建议首先手工制作它们,以便您了解它们的用途。然后开始使用模拟对象框架。但我将继续前进并使用 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 中的第一个实现。我希望测试失败,所以我要返回一个假数字:
- (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
我们再次运行测试,以确保它们仍然通过,并且它们确实通过了。重构应该只在“绿色”或通过状态下进行。所有测试都应该继续通过,无论重构是在测试代码还是在生产代码中完成。
第三次测试:没有匹配的 key ,在用户默认值中存储 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 已完成。第二步:最简单的代码通过。准备好?开始:
- (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"];
}
看看我们如何在进行时不断清洁?这些测试实际上变得更容易阅读了!
第四次测试:使用匹配的键,返回递增的值
现在我们要测试是否用户默认值有一些值,我们返回一个更大的值。我将使用任意值 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
。一切都会过去。我们完成了!等等,我们还没有完成。第 3 步:有什么要重构的吗?当我第一次写这篇文章时,我说,“不是真的。”但是在看完 Clean Code episode 3 之后再看,我可以听到鲍勃叔叔告诉我,“一个函数应该有多大?4 行就可以了,也许 5 行。6 行……好吧。10 行太大了。”那是7行。我错过了什么?它必须通过做不止一件事来违反功能规则。
再次,鲍勃叔叔:“真正确定一个函数做一件事的唯一方法是提取'直到你放弃。”前 4 行协同工作;他们计算实际值(value)。让我们选择它们,然后重构 ▶ 提取。按照第 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 行来了解它的作用。但是让用户默认 key 分布在两种方法中让我感到不舒服。让我们将其提取为 Example.m 开头的常量:
static NSString *const currentReminderIdKey = @"currentReminderId";
我将在生产代码中出现该键的任何地方使用该常量。但是测试代码继续使用文字。这可以防止我们意外更改该常量键。
结论
所以你有它。在五次测试中,我已经通过 TDD 找到了您要求的代码。希望它能让您更清楚地了解如何进行 TDD,以及为什么它值得。通过跟随 3 步华尔兹
你不只是在同一个地方结束。你最终得到:
所有这些好处将比在 TDD 上投入的时间节省更多的时间——不仅是长期的,而且是立即的。
有关涉及完整应用程序的示例,请获取 Test-Driven iOS Development 一书。这是 my review of the book 。
关于objective-c - 使用 OCUnit 的单元测试示例,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/13711911/