我真的很难理解单元测试.我确实理解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;
}

推荐答案

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,以及为什么值得这么做.跟着三步华尔兹

  1. 添加一个失败的测试
  2. 编写最简单的代码,即使看起来很愚蠢
  3. 重构(生产代码和测试代码)

你不只是在同一个地方结束.你最终会得到:

  • 支持依赖注入的隔离良好的代码,
  • 只实现已测试内容的极简代码,
  • 每种情况下的测试(测试本身经过验证),
  • 代码简洁明了,方法简单易读.

所有这些好处将比投资于TDD的时间节省更多的时间——不仅是长期的,而且是立即的.

举一个涉及完整应用程序的例子,获取Test-Driven iOS Development本书.这是my review of the book美元.

Objective-c相关问答推荐

如何使用 iOS 7 SpriteKit 粒子向非游戏的 iOS 应用程序添加粒子效果?

iOS:警告try 呈现其视图不在窗口层次 struct 中的 ViewController

如何测试 NSCFBolean 值?

如何通过仅更改高度而不更改宽度来调整 UILabel 的大小?

ios10:viewDidLoad 框架宽度/高度未正确初始化

使用 arc4random() 时如何 Select 值的范围

多个(两个)持久存储可以与一个对象模型一起使用,同时保持一个到另一个的关系吗?

在我的 UIImageView 的子类中没有调用 drawRect

在 viewDidAppear 之前无法正确设置框架

Objective-C 方法中的静态变量是否跨实例共享?

你如何安排一个块在下一次运行循环迭代中运行?

如何转储存储在 Objective-C 对象(NSArray 或 NSDictionary)中的数据

当服务器上的图像文件更改时,我的应用程序中的 SDWebImage 缓存图像会发生什么情况?

如何用 Java 开发 iPhone 应用程序?

在 Objective C 中,你能判断一个对象是否具有特定的属性或消息吗?

#ifdef DEBUG 与 #if DEBUG

iOS ScrollView 需要约束 y 位置或高度

笔尖中的原型单元而不是故事板

将 NSDate 舍入到最接近的 5 分钟

重复符号问题