如何对异步API进行单元测试?

64

我已经将Google Toolbox for Mac安装到Xcode中,并按照这里的说明设置了单元测试。一切运作正常,我可以很好地测试所有对象的同步方法。但是,我实际上想要测试的大多数复杂API都通过在委托上调用一个方法来异步返回结果 - 例如,调用文件下载和更新系统将立即返回,然后在文件完成下载时运行-fileDownloadDidComplete:方法。

如何将其作为单元测试进行测试?似乎我需要测试testDownload函数,或者至少让测试框架“等待”fileDownloadDidComplete:方法运行。

编辑:我现在已经切换到使用XCode内置的XCTest系统,并发现Github上的TVRSMonitor提供了一种非常简单的方式来使用信号量等待异步操作完成。

例如:

- (void)testLogin {
  TRVSMonitor *monitor = [TRVSMonitor monitor];
  __block NSString *theToken;

  [[Server instance] loginWithUsername:@"foo" password:@"bar"
                               success:^(NSString *token) {
                                   theToken = token;
                                   [monitor signal];
                               }

                               failure:^(NSError *error) {
                                   [monitor signal];
                               }];

  [monitor wait];

  XCTAssert(theToken, @"Getting token");
}

我将问题的措辞更改得更加通用,因为我想听听其他人对于单元测试异步操作的一般方法的看法。 - Kendall Helmstetter Gelner
3
请大家注意底部的“Thomas Tempelmann”的回答。 - Peter Lapisu
13个回答

52

我遇到了相同的问题,并找到了适合我的不同解决方案。

我使用“老派”的方法,通过使用信号量将异步操作转换为同步流程,具体如下:

// create the object that will perform an async operation
MyConnection *conn = [MyConnection new];
STAssertNotNil (conn, @"MyConnection init failed");

// create the semaphore and lock it once before we start
// the async operation
NSConditionLock *tl = [NSConditionLock new];
self.theLock = tl;
[tl release];    

// start the async operation
self.testState = 0;
[conn doItAsyncWithDelegate:self];

// now lock the semaphore - which will block this thread until
// [self.theLock unlockWithCondition:1] gets invoked
[self.theLock lockWhenCondition:1];

// make sure the async callback did in fact happen by
// checking whether it modified a variable
STAssertTrue (self.testState != 0, @"delegate did not get called");

// we're done
[self.theLock release]; self.theLock = nil;
[conn release];

确保调用

[self.theLock unlockWithCondition:1];

那么在代表中。


1
如果它永远不解锁怎么办...? - Julien
5
@Julian - 呃?你是程序员。你需要确保它能够解锁,这是算法的一部分。为了澄清:你的代理方法应该调用 "[self.theLock unlockWithCondition:1];"。而委托方法的调用由你调用的任何东西来保证,对吧?如果该委托从未被调用,那么你就发现了一个bug。 - Thomas Tempelmann
3
这个答案应该得到更多的认可,因为它是最好的答案,并且向我展示了如何将任何异步函数变成同步... 谢谢!!! - Peter Lapisu
2
@MattConnolly:这个答案不会锁定主线程:https://dev59.com/Nm025IYBdhLWcg3wJCVD#12710511 - Ben G
1
有一个关键点:[self.theLock unlockWithCondition:1]; 必须在另一个线程上调用,而不是与线程调用相同的线程:[self.theLock lockWhenCondition:1]; - user501836
显示剩余9条评论

44

虽然这个问题在一年前已经有了答案,但我不得不表示对给出的答案持不同意见。测试异步操作,尤其是网络操作,是非常普遍的要求,并且很重要。在给定的例子中,如果您依赖于实际的网络响应,那么您将失去一些重要的测试价值。具体来说,您的测试会依赖于您通信的服务器的可用性和功能正确性;这种依赖关系会使您的测试

  • 更加脆弱(如果服务器崩溃了怎么办?)
  • 覆盖范围更小(如何始终测试失败响应或网络错误?)
  • 显著变慢(想象一下测试以下内容:

单元测试应该在几分之一秒内运行。如果您每次运行测试都需要等待几秒钟的网络响应,则可能不太频繁地运行它们。

单元测试主要涉及到封装依赖项;从你所测试的代码的角度来看,发生了两件事情:

  1. 您的方法启动一个网络请求,通常是通过实例化 NSURLConnection 来实现的。
  2. 您指定的委托通过某些方法调用接收响应。

您的委托不应该关心响应来自哪里,无论是来自远程服务器的实际响应还是来自您的测试代码。通过简单地生成响应,您可以利用这一点来测试异步操作。您的测试将运行得更快,并且您可以可靠地测试成功或失败的响应。

这并不意味着您不应该对您正在使用的真实网络服务运行测试,但这些测试是集成测试,应该放在它们自己的测试套件中。那套测试的失败可能意味着网络服务已发生更改,或者由于某些原因正在停机。由于它们更脆弱,自动化它们的价值通常比自动化单元测试要小。

关于如何测试对网络请求的异步响应,有几种选择。您可以通过直接调用方法(例如[someDelegate connection:connection didReceiveResponse:someResponse])来仅测试委托对象本身。这样做在某种程度上是有效的,但是有些不正确。您的对象提供的委托可能只是特定NSURLConnection对象的委托链中多个对象之一;如果您直接调用委托的方法,您可能会错过由链条更高层次的另一个委托提供的重要功能。更好的选择是,您可以存根您创建的NSURLConnection对象,并让它将响应消息发送到其整个委托链。有一些库可以重新打开NSURLConnection(以及其他类)并为您执行此操作。例如:https://github.com/pivotal/PivotalCoreKit/blob/master/SpecHelperLib/Extensions/NSURLConnection%2BSpec.m

我搜索了有关此问题的信息,我绝对同意你的观点。尽管如此,我使用了St3fan的方法,因为我需要测试在WebView中是否正确显示图像。由于您的答案,我不需要从互联网下载它,但我仍然需要等待本地文件加载完成(在另一个线程中)。无论如何,非常感谢你们两个! - Julien
@Adam,你的解释很棒。我非常喜欢你生动的风格。 - i.AsifNoor

19

St3fan,你是个天才。非常感谢!

我是按照你的建议实现的。

'Downloader' 定义了一个协议,并使用方法 DownloadDidComplete 在完成时触发。 有一个 BOOL 成员变量 'downloadComplete' 用于终止运行循环。

-(void) testDownloader {
 downloadComplete = NO;
 Downloader* downloader = [[Downloader alloc] init] delegate:self];

 // ... irrelevant downloader setup code removed ...

 NSRunLoop *theRL = [NSRunLoop currentRunLoop];

 // Begin a run loop terminated when the downloadComplete it set to true
 while (!downloadComplete && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

}


-(void) DownloaderDidComplete:(Downloader*) downloader withErrors:(int) errors {
    downloadComplete = YES;

    STAssertNotEquals(errors, 0, @"There were errors downloading!");
}

当然,运行循环可能会一直运行下去...我以后会改进它的!


1
这个非常好用!你可以将 beforeDate: 更改为未来的 30 秒之类的内容,以避免无限运行。 - freespace
我建议您将[NSDate dateWithTimeIntervalSinceNow:1.0f]用作beforeDate参数。 - Roman Truba
@RomanTruba 为什么?这很简洁,而且重点是允许运行循环处理一些事件并返回。难道你想要不断地返回吗?我猜如果你正在使用运行循环编写“单元测试”,那么你实际上并没有在运行单元测试。 - Cameron Lowell Palmer
@CameronLowellPalmer 现在我不记得为什么了。也许有某个东西出了问题。 - Roman Truba
@RomanTruba 我了解那种感觉。也许你想保证可以尽快退出,而不是等待事件允许循环退出。 - Cameron Lowell Palmer

16

我写了一个小助手,可以轻松地测试异步API。首先是这个助手:

static inline void hxRunInMainLoop(void(^block)(BOOL *done)) {
    __block BOOL done = NO;
    block(&done);
    while (!done) {
        [[NSRunLoop mainRunLoop] runUntilDate:
            [NSDate dateWithTimeIntervalSinceNow:.1]];
    }
}
您可以这样使用它:

你可以像这样使用它:

hxRunInMainLoop(^(BOOL *done) {
    [MyAsyncThingWithBlock block:^() {
        /* Your test conditions */
        *done = YES;
    }];
});

只有当done变为TRUE时,它才会继续,因此请确保在完成后设置它。当然,如果你愿意,也可以给辅助程序添加超时。


2
这是目前为止最好、最有用的答案!谢谢! - Fabio Poloni
非常非常有用!谢谢 - klaustopher
在应用程序产品代码中使用while循环阻塞主线程是疯狂的,但我可以在UT中使用这段代码。谢谢! - Itachi

8
这有点棘手。我认为您需要在测试中设置一个运行循环,并且能够将该运行循环指定给异步代码。否则,回调函数不会发生,因为它们是在运行循环上执行的。
我猜你可以在循环中运行运行循环一段时间。并让回调函数设置一些共享状态变量。或者甚至简单地要求回调函数终止运行循环。这样,您就知道测试已经结束了。如果发生超时,您应该能够在一定时间后停止循环以检查超时是否发生。
我从未做过这个,但我认为我很快就必须这样做了。请分享您的结果 :-)

6
如果您正在使用诸如AFNetworking或ASIHTTPRequest之类的库,并通过NSOperation(或使用这些库的子类)来管理请求,则可以将它们与测试/开发服务器一起测试,只需使用NSOperationQueue即可:
在测试中:
// create request operation

NSOperationQueue* queue = [[NSOperationQueue alloc] init];
[queue addOperation:request];
[queue waitUntilAllOperationsAreFinished];

// verify response

这基本上会运行一个runloop直到操作完成,允许所有回调在后台线程上正常发生。

非常高兴在这里找到了您的回复 - 您的回答确保让我省去了许多麻烦 - 谢谢 :) - fatuous.logic
请注意,waitUntilAllOperationsAreFinished仅等待当前线程完成。如果您正在使用类似AFJSONRequestOperation的AFNetworking类方法,则会有额外的块(成功、失败)在不同的线程上运行。 - Snowcrash
AFNetworking 处理来自操作系统的回调并运行在任何操作系统选择的任意队列上,默认情况下,它会在主队列上调用您的回调函数。(如果您喜欢,您可以告诉 AFNetworking 为回调使用指定的后台队列)。 - Matt Connolly

6

为了详细说明@St3fan的解决方案,在初始化请求之后,您可以尝试以下操作:

- (BOOL)waitForCompletion:(NSTimeInterval)timeoutSecs
{
    NSDate *timeoutDate = [NSDate dateWithTimeIntervalSinceNow:timeoutSecs];

    do
    {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:timeoutDate];
        if ([timeoutDate timeIntervalSinceNow] < 0.0)
        {
            break;
        }
    }
    while (!done);

    return done;
}

另外一种方法:
//block the thread in 0.1 second increment, until one of callbacks is received.
    NSRunLoop *theRL = [NSRunLoop currentRunLoop];

    //setup timeout
    float waitIncrement = 0.1f;
    int timeoutCounter  = (int)(30 / waitIncrement); //30 sec timeout
    BOOL controlConditionReached = NO;


    // Begin a run loop terminated when the downloadComplete it set to true
    while (controlConditionReached == NO)
    {

        [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:waitIncrement]];
        //control condition is set in one of your async operation delegate methods or blocks
        controlConditionReached = self.downloadComplete || self.downloadFailed ;

        //if there's no response - timeout after some time
        if(--timeoutCounter <= 0)
        {
            break;
        }
    }

3
我发现使用https://github.com/premosystems/XCAsyncTestCase非常方便。

它为XCTestCase添加了三个非常方便的方法。

@interface XCTestCase (AsyncTesting)

- (void)waitForStatus:(XCTAsyncTestCaseStatus)status timeout:(NSTimeInterval)timeout;
- (void)waitForTimeout:(NSTimeInterval)timeout;
- (void)notify:(XCTAsyncTestCaseStatus)status;

@end

允许非常干净的测试的工具。以下是该项目本身的一个示例:
- (void)testAsyncWithDelegate
{
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.google.com"]];
    [NSURLConnection connectionWithRequest:request delegate:self];
    [self waitForStatus:XCTAsyncTestCaseStatusSucceeded timeout:10.0];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    NSLog(@"Request Finished!");
    [self notify:XCTAsyncTestCaseStatusSucceeded];
}

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
    NSLog(@"Request failed with error: %@", error);
    [self notify:XCTAsyncTestCaseStatusFailed];
}

2
我实施了Thomas Tempelmann提出的解决方案,总体上对我来说效果很好。
但是,有一个问题。假设要测试的单元包含以下代码:
dispatch_async(dispatch_get_main_queue(), ^{
    [self performSelector:selector withObject:nil afterDelay:1.0];
});

选择器可能永远不会被调用,因为我们告诉主线程要锁定,直到测试完成:
[testBase.lock lockWhenCondition:1];

总的来说,我们可以完全摆脱 NSConditionLock,而是直接使用 GHAsyncTestCase 类。
以下是我在代码中使用它的方式:
@interface NumericTestTests : GHAsyncTestCase { }

@end

@implementation NumericTestTests {
    BOOL passed;
}

- (void)setUp
{
    passed = NO;
}

- (void)testMe {

    [self prepare];

    MyTest *test = [MyTest new];
    [test run: ^(NSError *error, double value) {
        passed = YES;
        [self notify:kGHUnitWaitStatusSuccess];
    }];
    [test runTest:fakeTest];

    [self waitForStatus:kGHUnitWaitStatusSuccess timeout:5.0];

    GHAssertTrue(passed, @"Completion handler not called");
}

更加清洁,而且不会阻塞主线程。

1

我刚刚写了一篇关于这个的博客文章(实际上,我开始写博客是因为我认为这是一个有趣的话题)。最终,我使用方法交换(method swizzling)来调用完成处理程序,以便在不等待的情况下使用任何想要的参数,这对于单元测试似乎很好。类似这样:

- (void)swizzledGeocodeAddressString:(NSString *)addressString completionHandler:(CLGeocodeCompletionHandler)completionHandler
{
    completionHandler(nil, nil); //You can test various arguments for the handler here.
}

- (void)testGeocodeFlagsComplete
{
    //Swizzle the geocodeAddressString with our own method.
    Method originalMethod = class_getInstanceMethod([CLGeocoder class], @selector(geocodeAddressString:completionHandler:));
    Method swizzleMethod = class_getInstanceMethod([self class], @selector(swizzledGeocodeAddressString:completionHandler:));
    method_exchangeImplementations(originalMethod, swizzleMethod);

    MyGeocoder * myGeocoder = [[MyGeocoder alloc] init];
    [myGeocoder geocodeAddress]; //the completion handler is called synchronously in here.

    //Deswizzle the methods!
    method_exchangeImplementations(swizzleMethod, originalMethod);

    STAssertTrue(myGeocoder.geocoded, @"Should flag as geocoded when complete.");//You can test the completion handler code here. 
}

博客文章,供有兴趣的人参考。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接