Asynchrones Unit Testing mit NSOperation und Blocks unter iOS im Rahmen eines gekapselten Service-Model-Layer (Objective-C)

Veröffentlicht am 19.08.2014

Das Testen von asynchronen Klassen bzw. Methoden ist eine spannende und je nach Technologie herausfordernde Aufgabenstellung. Im folgenden Blogbeitrag möchte ich Strategien mit Objective-C unter iOS aufzeigen. Dazu habe ich eine Demo-App implementiert, welche meinen RSS Blog Feed runterlädt und in der Debug Console ausgibt. Da das UI nicht blockieren soll während der Download läuft (Responsive), greife ich bei der Implementierung auf die beiden Klassen NSOperationQueue und NSOperation zurück. Diese ermöglichen bestimmte Aufgaben asynchron im Hintergrund auszuführen. 

Da das Herunterladen des Feed eine eigenständige Aufgabe darstellt, sollte auch die Implementierung in eine eigene Klasse ausgelagert werden. Aus diesem Grund habe ich eine einfache Ableitung der Klasse NSOperation mit dem Namen DownloadRSSOperation definiert. 

#import <Foundation/Foundation.h>

@interface DownloadRSSOperation : NSOperation

@property (atomic, strong) NSString* url;
@property (atomic, strong) NSString* result;

@end

#import "DownloadRSSOperation.h"

@implementation DownloadRSSOperation

@synthesize url = _url;
@synthesize result = _result;

- (void)main
{
    _result = nil;
    
    // Start sync download
    NSURLRequest* urlRequest = [NSURLRequest requestWithURL:[NSURL URLWithString:_url]];

    // Sleep
    [NSThread sleepForTimeInterval:1.0f];
    
    NSURLResponse* response = nil;
    NSError* error = nil;
    NSData* data = [NSURLConnection sendSynchronousRequest:urlRequest returningResponse:&response error:&error];
    
    if (error == nil)
    {
        // Danger!
        _result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    }
}

@end

Für den Download des Feed verwende ich die Klasse NSURL und starte einen synchronen Request (NSURL kann auch asynchron mit Delegates verwendet werden). Da der Source-Code innerhalb der Operation synchron ausgeführt wird, muss lediglich die main Methode der NSOperation Klasse überschrieben werden (Eine NSOperation kann wiederum in sich selbst asynchron programmiert werden - Siehe start Methode). Das Ergebnis des Downloads speichere ich, im Rahmen dieses einfachen Beispiels, in einen Result String.

Da die Geschäftslogik meiner App nach Möglichkeit unabhängig von der Visualisierung verwaltbar sein soll, habe ich mich dazu entschieden eine eigene Service-Klasse zu implementieren. Die Service-Klasse repräsentiert meinen Model-Layer und ist als Singleton implementiert. Weiterhin verwendet die Klasse eine NSOperationQueue zur Abarbeitung der Downloadanfragen in Form der DownloadRSSOperations. 

#import <Foundation/Foundation.h>

typedef void (^DownloadRSSCompletionBlock) (BOOL success, NSString* result);

@interface Service : NSObject

@property (nonatomic, strong) NSOperationQueue* queue;

- (id)init;
- (void)downloadRSSWithCompletionBlock:(DownloadRSSCompletionBlock)block;

+ (Service*)sharedService;

@end

#import "Service.h"
#import "DownloadRSSOperation.h"

@implementation Service

@synthesize queue = _queue;

- (id)init
{
    if (self = [super init])
    {
        _queue = [[NSOperationQueue alloc] init];
        _queue.maxConcurrentOperationCount = 1;
    }
    
    return self;
}

- (void)downloadRSSWithCompletionBlock:(DownloadRSSCompletionBlock)block
{
    DownloadRSSOperation* operation = [[DownloadRSSOperation alloc] init];
    operation.url = @"http://www.davidchristian.de/index.php/blog/rss";
    
    __weak DownloadRSSOperation* weakOp = operation;
    
    [operation setCompletionBlock:^
    {
        NSLog(@"setCompletionBlock");
        
        if (weakOp.result != nil)
        {
            NSString* copy = [weakOp.result copy];
            
            // Always dispatch callbacks on main queue
            dispatch_async(dispatch_get_main_queue(),
                           ^{
                               // Copy string!!
                               block(YES, copy);
                           });
        }
        else
        {
            dispatch_async(dispatch_get_main_queue(),
                           ^{
                               block(NO, nil);
                           });
        }
    }];
    
    [_queue addOperation:operation];
}

+ (Service*)sharedService
{
    static dispatch_once_t pred = 0;
    __strong static id _sharedObject = nil;
    
    dispatch_once(&pred, ^{
        _sharedObject = [[self alloc] init];
    });
    
    return _sharedObject;
}

@end

Die Methode downloadRSSWithCompletionBlock ermöglicht einem Aufrufer den asynchronen Download des RSS Feed. Über den Callbackblock DownloadRSSCompletionBlock erhält der Aufrufer nach dem Download das Ergebnis und kann dieses weiterverarbeiten. Intern wird innerhalb der Methode einfach die DownloadRSSOperation konfiguriert und auf der Queue abgelegt. Über den CompletionBlock der Operation wird das Ergebnis ausgewertet und an den Callbackblock (DownloadRSSCompletionBlock) des Aufrufers übermittelt.

Wenn man die Service-Klasse innerhalb eines ViewControllers verwendet, kann ohne Probleme der Download durchgeführt werden.

- (IBAction)downloadPressed
{
    [[Service sharedService] downloadRSSWithCompletionBlock:^(BOOL success, NSString* result)
    {
        if (success)
            NSLog(@"Download result > %@", result);
        else
            NSLog(@"Download failed!");
    }];
}

Möchte man dieses asynchrone Konstrukt testen, stellt sich direkt die Frage nach dem Wie, da ein Unit Test des XCTest Frameworks normalerweise synchron durchläuft. Mit Hilfe der Klasse NSRunLoop und der Methode runMode:beforeDate: können wir trotzdem einen asynchronen Unit Test implementieren.

- (void)testDownloadRSS
{
    // Arrange
    __block bool running = true;
    
    // Act
    [[Service sharedService] downloadRSSWithCompletionBlock:^(BOOL success, NSString* result)
     {
         if (success)
         {
             NSLog(@"Download result > %@", result);
             XCTAssertTrue([result length] > 0, @"Download");
         }
         else
         {
             NSLog(@"Download failed!");
             XCTAssertTrue(false, @"Download failed");
         }
         
         running = false;
     }];
    
    while (running && [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
}

Der Trick ist einfach eine while-Schleife mit dem RunLoop geschickt zu verwenden und erst aus dem Test "zu springen", wenn die asynchrone Methode auch wirklich beendet wurde. Dazu setzen wir running nach dem Callback-Aufruf auf false. 

Bei Bedarf kann auch eine Timeout-Bedingung ergänzt werden. So wird beispielsweise auch getestet, ob das Callback tatsächlich irgendwann durch den Service aufgerufen wird.

while (running && [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]])
{
    // Timeout if the callback will not be called...
    NSTimeInterval executionTime = [[NSDate date] timeIntervalSinceDate:methodStart];

    NSLog(@"Time %F", executionTime);

    if (executionTime > 3)
    {
        XCTAssertTrue(false, @"downloadRSSWithCompletionBlock - Timeout");

        running = false;
    }
}

Da unsere DownloadRSSOperation innerhalb der main Methode synchron implementiert ist, können wir diese auch unabhängig des Service testen.

- (void)testDownloadRSSOperationAlone
{
    // Arrange
    DownloadRSSOperation* operation = [[DownloadRSSOperation alloc] init];
    operation.url = @"http://www.davidchristian.de/index.php/blog/rss";
    
    // Act
    [operation main];
    
    // Assert
    XCTAssertTrue(operation.result != nil && [operation.result length] > 0, @"Download");
}

Das skizzierte Beispiel ist vereinfacht, jedoch können solche asynchrone Konstrukte auch unter iOS getestet werden. Auch Queues mit Operations, welche in sich asynchron programmiert sind, können auf diese Art und Weise, über die Service-Klasse getestet werden.

Xcode 6 und iOS 8

Mit der Einführung von iOS 8 können wir asynchrone Tests eleganter und einfacherer implementieren. Dazu steht uns die Klasse XCTestExpectation und die neue Methode waitForExpectationsWithTimeout:handler: zur Verfügung. Der Test kann wie folgt umgeschrieben werden:

- (void)testDownloadRSS_8
{
    // Arrange
    XCTestExpectation* expectation = [self expectationWithDescription:@"Download RSS"];
    
    // Act
    [[Service sharedService] downloadRSSWithCompletionBlock:^(BOOL success, NSString* result)
     {
         [expectation fulfill];
         
         if (success)
         {
             NSLog(@"Download result > %@", result);
             XCTAssertTrue([result length] > 0, @"Download");
         }
         else
         {
             NSLog(@"Download failed!");
             XCTAssertTrue(false, @"Download failed");
         }
     }];
    
    // iOS 8
    [self waitForExpectationsWithTimeout:3.0 handler:^(NSError *error)
     {
         NSLog(@"Time %@", error);
     }];
}

Wir erzeugen eine "Erwartung" und wünschen uns, dass unser Callback der Methode downloadRSSWithCompletionBlock ausgeführt wird. Falls unser Block aufgerufen wird, rufen wir die Methode fulfill auf und bestätigen unsere Erwartung. Wir "starten" den Test über die Methode waitForExpectationsWithTimeout:handler: und legen für alle Erwartungen ein Timeout von 3 Sekunden fest. Sollte eine Erwartung nicht innerhalb dieses Zeitlimits bestätigt werden, so wird der Test fehlschlagen.

Gedanken zu NSOperation und deren Verwendung

Im weiteren Verlauf der Entwicklung der App würde man sicherlich einen Parser für das XML implementieren und die Daten möglicherweise lokal persistieren. Auch diese Aufgaben sollten asynchron im Hintergrund erledigt werden. In diesem Fall wäre zu überlegen, ob man für das Parsen sowie Speichern eine eigene Operation implementiert oder den entsprechenden Source-Code in der vorhandenen DownloadOperation ergänzt. Beide Varianten sind eher ungünstig. Neue Operations erhöhen die Komplexität und erleichtern nicht notwendigerweise die Testbarkeit. Die Erweiterung der vorhandenen DownloadOperation würde das isolierte Testen des Parsers oder der Speicherung verhindern. Aus diesem Grund wäre die bessere Überlegung sowohl den Source-Code für den Download, das Parsen als auch die Persistierung in eigene Klassen auszulagern. Innerhalb der entsprechenden Methoden dieser Klassen wird synchron programmiert. Nichtsdestotrotz können diese Klassen asynchron innerhalb einer Operation ausgeführt werden. Über einen einfachen Unit Test können die Klassen isoliert getestet werden. Das Zusammenspiel innerhalb der Operation kann über den Service getestet werden.

- (void)main
{
    _result = nil;
    
    // Start sync download
    NSString* response = [DownloadURL downloadURLAsString:_url];
    
    // Parse
    _result = [XMLFeedParser parse:response];

	// Update
	bool updated = [FeedDatabase updateFeed:_result];

	...
}

NSOperations sollte man sich als eine Art asynchroner Wrapper vorstellen, in dem ohne viel Source-Code entsprechende Klassen aufgerufen werden.

GitHub Beispiel

Sie finden das dargestellte Beispiel in meinem GitHub Repository. Viel Spaß beim ausprobieren!
https://github.com/dctdct/ios-async-unit-testing