【问题标题】:Writing a unit test to verify NSTimer was started编写单元测试来验证 NSTimer 是否已启动
【发布时间】:2014-01-29 05:54:47
【问题描述】:

我正在使用XCTestOCMock 为iOS 应用程序编写单元测试,我需要指导如何最好地设计一个单元测试来验证方法是否导致NSTimer 被启动。

被测代码:

- (void)start {
    ...
    self.timer = [NSTimer timerWithTimeInterval:1.0
                                         target:self
                                       selector:@selector(tick:)
                                       userInfo:nil
                                        repeats:YES];
    NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
    [runLoop addTimer:self.timer forMode:NSDefaultRunLoopMode];
    ...
}

我要测试的是计时器是用正确的参数创建的,并且计时器被安排在运行循环上运行。

我想到了以下我不满意的选项:

  1. 实际上等待计时器触发。 (我不喜欢它的原因:糟糕的单元测试实践。它更像是一个缓慢的集成测试。)
  2. 将计时器启动代码提取到私有方法中,在类扩展文件中将该私有方法公开给单元测试,并使用模拟期望来验证私有方法是否被调用。 (我不喜欢它的原因:它验证方法是否被调用,但不是该方法实际设置计时器以正确运行。另外,公开私有方法不是一个好习惯。)
  3. 为被测代码提供一个模拟NSTimer。 (我不喜欢它的原因:无法验证它是否真的被安排运行,因为计时器是通过运行循环而不是从某些NSTimer start 方法启动的。)
  4. 提供一个模拟 NSRunLoop 并验证 addTimer:forMode: 是否被调用。 (我不喜欢它的原因:我必须在运行循环中提供一个接口?这看起来很古怪。)

有人可以提供一些单元测试指导吗?

【问题讨论】:

  • 您不必验证它是否实际上已安排运行。调度和运行位不是您的代码;即使你对它们进行了单元测试并且它们失败了,你能做些什么呢?您只需要确保您的代码在该点之前完成它需要做的事情,并与框架正确连接。因此,我认为第三个是答案——告诉 NSTimer 类对象的模拟以期望 timerWithTimeInterval:...
  • 嘲讽NSRunLoop 也有道理; Mike Ash 有一篇文章可能会对那里有所帮助:mikeash.com/pyblog/…
  • 乔希,感谢您的指导!我最终对这个问题进行了更多思考,您对“调度和运行位不是您的代码”的评论提醒我,我曾在某处读过,“不要测试 Apple 的实现”。直到现在才有意义。我试图设计我的测试来涵盖所有可能的场景,但是当你使用别人的代码做事情时你不能这样做,你所能做的就是测试发生交互。
  • 很高兴能为您提供帮助。希望你会考虑用你解决这个问题的方式写一个答案。

标签: ios objective-c unit-testing nstimer


【解决方案1】:

好的!花了一段时间才弄清楚如何做到这一点。我将完整地解释我的思考过程。抱歉啰嗦了。

首先,我必须弄清楚我在测试什么。我的代码做了两件事:它启动一个重复计时器,然后计时器有一个回调,让我的代码做其他事情。这是两个独立的行为,这意味着两个不同的单元测试。

那么如何编写单元测试来验证您的代码是否正确启动了重复计时器?您可以在单元测试中测试三件事:

  • 方法的返回值
  • 系统状态或行为的更改(最好通过公共接口提供)
  • 您的代码与您无法控制的其他代码的交互

对于NSTimerNSRunLoop,我必须测试交互,因为无法从外部验证计时器是否配置正确。说真的,没有repeats 属性。您必须拦截创建计时器本身的方法调用。

接下来,我意识到如果我使用+scheduledTimerWithTimeInterval:target:selector:userInfo:repeats 创建计时器,我根本不必触摸NSRunLoop,它会自动启动计时器。我需要测试的互动少了一次。

最后,要创建调用 +scheduledTimerWithTimeInterval:target:selector:userInfo:repeats 的期望,您必须模拟 NSTimer 类,幸好 OCMock 现在可以做到。下面是测试的样子:

id mockTimer = [OCMockObject mockForClass:[NSTimer class]];
[[mockTimer expect] scheduledTimerWithTimeInterval:1.0
                                            target:[OCMArg any]
                                          selector:[OCMArg anySelector]
                                          userInfo:[OCMArg any]
                                           repeats:YES];

<your code that should create/schedule NSTimer>

[mockTimer verify];

看着这个测试,我想,“等等,你怎么能真正测试定时器配置了正确的目标和选择器?”好吧,我终于意识到我实际上不应该关心它是否配置了特定的目标和选择器,我应该只关心当计时器触发时,它会做我需要它做的事情。这对于编写好的、面向未来的单元测试来说是非常重要的一点:真的尽量不要依赖私有接口或实现细节,因为这些事情会发生变化。相反,测试您的代码不会改变的行为,并通过公共接口进行。

这将我们带到第二个单元测试:计时器是否完成了我需要它做的事情?为了测试这一点,幸好NSTimer-fire,这会导致计时器在目标上执行选择器。因此,您甚至不需要创建一个假的 NSTimer,或者执行提取和覆盖来创建自定义模拟计时器,您所要做的就是让它撕裂:

id mockObserver = [OCMockObject observerMock];
[[NSNotificationCenter defaultCenter] addMockObserver:mockObserver
                                                 name:@"SomeNotificationName"
                                               object:nil];
[[mockObserver expect] notificationWithName:@"SomeNotificationName"
                                     object:[OCMArg any]];
[myCode startTimer];

[myCode.timer fire];

[mockObserver verify];
[[NSNotificationCenter defaultCenter] removeObserver:mockObserver];

关于本次测试的几点意见:

  • 当计时器触发时,测试期望将NSNotification 发布到默认NSNotificationCenterOCMock 没有让您失望:通知广播测试就是这么简单。
  • 要真正触发计时器触发,您需要对计时器的引用。我的测试类没有在公共接口中公开 NSTimer,所以我这样做的方法是创建一个类扩展,将私有 NSTimer 属性公开给我的测试,as described in this SO post

【讨论】:

  • 我是一个 TDD 新手,所以我可能忽略了一些东西,但我不相信你的测试能完成任何事情。当然,当你运行它时,你会得到一个绿色标记,但基本上你已经验证了 NSTimer 确实像宣传的那样工作。当您是 Apple 的测试人员时,这很有用,但是它告诉您有关您的代码的什么信息?据我所知,什么都没有。在您的应用中,您可能会拼错计时器的选择器、指定错误的目标、在错误的时间触发计时器并忘记使其无效,但根据上面的测试,您做得很好。
  • 测试的重点不是验证 NSTimer 是否做了它所做的事情——而是当它做它做的事情时,你的被测代码 (CUT) 做正确的事情。在 good 单元测试中,您想要验证它是否做了正确的 public 事情——改变一个公共变量,以一种公开可见的方式改变它的状态,调用一个方法在第三方对象上,返回正确的值,或者在这种情况下,广播正确的通知。
  • 你可以拼错计时器的选择器,当然,但如果你拼错了,它就不会广播通知。或者您可以安排它每 10 秒运行一次,而不是每秒运行一次,但这就是为什么我设置模拟计时器以期望它接收 scheduleTimerWithTimeInterval:1.0。最终,单元测试可以也不会提供 100% 的保证,即您已经正确地完成了所有操作。我的意思是,即使期待 scheduleTimerWithTimeInterval: 也是一个非常脆弱和脆弱的单元测试,因为有人可以使用另一个初始化程序创建一个计时器。这就是集成/功能测试应该填补漏洞的地方
【解决方案2】:

我真的很喜欢 Richard 的第一种方法,我稍微扩展了代码以使用块调用以避免引用私有 NSTimer 属性。

[[mockAPIClient expect] someMethodSuccess:[OCMIsNotNilConstraint constraint]
                                  failure:[OCMIsNotNilConstraint constraint];

id mockTimer = [OCMockObject mockForClass:[NSTimer class]];
[[[mockTimer expect] andDo:^(NSInvocation *invocation) {
    SEL selector = nil;
    [invocation getArgument:&selector atIndex:4];
    [testSubject performSelector:selector];
}] scheduledTimerWithTimeInterval:10.0
                           target:testSubject
                         selector:[OCMArg anySelector]
                         userInfo:nil
                          repeats:YES];

[testSubject viewWillAppear:YES];

[mockTimer verify];
[mockAPIClient verify];

【讨论】:

  • 不错!没有意识到您可以通过这种方式获取发送到模拟对象的参数。不必深入研究类的私有实现是一种改进。我确实认为这是以测试可读性为代价的,但与大多数事情一样,这是一种权衡。
猜你喜欢
  • 1970-01-01
  • 2017-03-22
  • 1970-01-01
  • 1970-01-01
  • 2020-05-08
  • 2013-07-15
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多