web-dev-qa-db-fra.com

Comment tester des API asynchrones?

J'ai installé Google Toolbox pour Mac dans Xcode et suivi les instructions pour configurer les tests unitaires trouvés ici .

Tout fonctionne très bien et je peux tester parfaitement mes méthodes synchrones sur tous mes objets. Cependant, la plupart des API complexes pour lesquelles je souhaite tester les résultats renvoyés de manière asynchrone en appelant une méthode sur un délégué - par exemple, un appel à un système de téléchargement et de mise à jour de fichier renverront immédiatement et exécuteront ensuite une méthode -fileDownloadDidComplete: à la fin du téléchargement du fichier. .

Comment pourrais-je tester cela comme un test unitaire? 

Il semble que je voudrais utiliser la fonction testDownload, ou du moins le framework de test, pour 'attendre' que la méthode fileDownloadDidComplete: soit exécutée.

EDIT: Je suis maintenant passé à l’utilisation du système XCTest intégré à XCode et j’ai découvert que TVRSMonitor on Github fournit un moyen extrêmement simple d’utiliser des sémaphores pour attendre la fin des opérations asynchrones. 

Par exemple:

- (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");
}
64
Ben Clayton

J'ai rencontré la même question et trouvé une solution différente qui fonctionne pour moi.

J'utilise l'approche "old school" pour transformer les opérations asynchrones en flux sync en utilisant un sémaphore comme suit:

// 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];

Assurez-vous d'invoquer

[self.theLock unlockWithCondition:1];

Dans le (s) délégué (s) alors.

52
Thomas Tempelmann

J'apprécie que cette question ait été posée et à laquelle il ait été répondu il y a presque un an, mais je ne peux pas m'empêcher d'être en désaccord avec les réponses données. Tester des opérations asynchrones, en particulier des opérations de réseau, est une exigence très courante et qu'il est important de bien faire. Dans l'exemple donné, si vous dépendez des réponses réelles du réseau, vous perdez une partie de la valeur importante de vos tests. Plus précisément, vos tests deviennent dépendants de la disponibilité et de l'exactitude fonctionnelle du serveur avec lequel vous communiquez. cette dépendance rend vos tests 

  • plus fragile (que se passe-t-il si le serveur tombe en panne?)
  • moins complet (comment testez-vous systématiquement une réponse à une panne ou une erreur réseau?)
  • significativement plus lent, imaginez tester ceci:

Les tests unitaires doivent se dérouler en fractions de seconde. Si vous devez attendre une réponse du réseau de plusieurs secondes à chaque fois que vous exécutez vos tests, vous aurez moins de chances de les exécuter fréquemment.

Les tests unitaires consistent principalement à encapsuler des dépendances; Du point de vue de votre code sous test, deux choses se passent:

  1. Votre méthode lance une requête réseau, probablement en instanciant une connexion NSURLConnection.
  2. Le délégué que vous avez spécifié reçoit une réponse via certains appels de méthode.

Votre délégué ne veut pas ou ne devrait pas se soucier de l’origine de la réponse, qu’il s’agisse d’une réponse réelle d’un serveur distant ou de votre code de test. Vous pouvez en tirer parti pour tester les opérations asynchrones en générant simplement les réponses vous-même. Vos tests seront beaucoup plus rapides et vous pourrez tester de manière fiable les réponses en cas de succès ou d’échec.

Cela ne veut pas dire que vous ne devriez pas exécuter de tests sur le vrai service Web avec lequel vous travaillez, mais que ce sont des tests d'intégration et qu'ils appartiennent à leur propre suite de tests. Les échecs dans cette suite peuvent signifier que le service Web a changé ou est simplement en panne. Comme ils sont plus fragiles, leur automatisation a généralement moins de valeur que celle de vos tests unitaires.

En ce qui concerne le test des réponses asynchrones à une requête réseau, vous avez plusieurs options. Vous pouvez simplement tester le délégué de manière isolée en appelant directement les méthodes (par exemple [connexion SomeDelegate: connection didReceiveResponse: someResponse]). Cela fonctionnera un peu, mais est légèrement faux. Le délégué que votre objet fournit peut être l'un des nombreux objets de la chaîne de délégués pour un objet NSURLConnection spécifique; Si vous appelez directement les méthodes de votre délégué, il se peut qu'il manque certaines fonctionnalités clés fournies par un autre délégué tout en haut de la chaîne. Vous pouvez également remplacer l'objet NSURLConnection que vous avez créé et lui faire envoyer les messages de réponse à l'ensemble de la chaîne de délégués. Certaines bibliothèques vont rouvrir NSURLConnection (entre autres classes) et le feront pour vous. Par exemple: https://github.com/pivotal/PivotalCoreKit/blob/master/SpecHelperLib/Extensions/NSURLConnection%2BSpec.m

44
Adam Milligan

St3fan, tu es un génie. Merci beaucoup! 

Voici comment je l'ai fait en utilisant votre suggestion.

'Downloader' définit un protocole avec une méthode DownloadDidComplete qui se déclenche à la fin. Il existe une variable membre BOOL 'downloadComplete' qui est utilisée pour terminer la boucle d'exécution.

-(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!");
}

La boucle peut potentiellement fonctionner indéfiniment. Je l’améliorerai plus tard!

19
Ben Clayton

J'ai écrit un petit assistant qui facilite le test des API asynchrones. D'abord l'assistant:

static inline void hxRunInMainLoop(void(^block)(BOOL *done)) {
    __block BOOL done = NO;
    block(&done);
    while (!done) {
        [[NSRunLoop mainRunLoop] runUntilDate:
            [NSDate dateWithTimeIntervalSinceNow:.1]];
    }
}

Vous pouvez l'utiliser comme ceci:

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

Il ne continuera que si done devient TRUE, assurez-vous de le définir une fois terminé. Bien sûr, vous pouvez ajouter un délai d'attente à l'aide si vous le souhaitez,

16
Holtwick

C'est délicat. Je pense que vous aurez besoin de configurer un runloop lors de votre test et également de pouvoir spécifier ce runloop dans votre code async. Sinon, les rappels ne se produiront pas car ils sont exécutés sur un runloop.

Je suppose que vous pourriez simplement exécuter le runloop pendant une courte durée dans une boucle. Et laissez le rappel définir une variable d'état partagée. Ou peut-être même simplement demander au rappel de mettre fin au runloop. De cette façon, vous savez que le test est terminé. Vous devriez pouvoir vérifier les délais en arrêtant la boucle après un certain temps. Si cela se produit, un délai d'attente s'est écoulé.

Je n'ai jamais fait ça mais j'y reviendrai bientôt. S'il vous plaît partagez vos résultats :-)

8
Stefan Arentz

Pour élaborer sur la solution de @ St3fan, vous pouvez essayer ceci après avoir lancé la demande:

- (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;
}

Autrement:

//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;
        }
    }
6
Daniel

Si vous utilisez une bibliothèque telle que AFNetworking ou ASIHTTPRequest et gérez vos demandes via NSOperation (ou une sous-classe avec ces bibliothèques), il est facile de les tester sur un serveur test/dev avec NSOperationQueue:

En test:

// create request operation

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

// verify response

Cela exécute essentiellement une boucle de cycle jusqu'à la fin de l'opération, permettant ainsi à tous les rappels de se produire sur les threads d'arrière-plan comme ils le feraient normalement.

6
Matt Connolly

Je trouve cela très pratique d’utiliser https://github.com/premosystems/XCAsyncTestCase

Il ajoute trois méthodes très pratiques à XCTestCase 

@interface XCTestCase (AsyncTesting)

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

@end

qui permettent des tests très propres. Un exemple tiré du projet lui-même:

- (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];
}
3
xverges

J'ai implémenté la solution proposée par Thomas Tempelmann et globalement, cela fonctionne très bien pour moi.

Cependant, il y a un gotcha. Supposons que l'unité à tester contienne le code suivant:

dispatch_async(dispatch_get_main_queue(), ^{
    [self performSelector:selector withObject:nil afterDelay:1.0];
});

Le sélecteur ne peut jamais être appelé car nous avons demandé au thread principal de se verrouiller jusqu'à la fin du test:

[testBase.lock lockWhenCondition:1];

Globalement, nous pourrions nous débarrasser complètement de NSConditionLock et simplement utiliser la classe GHAsyncTestCase .

Voici comment je l'utilise dans mon code:

@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");
}

Beaucoup plus propre et ne bloque pas le fil principal.

2
bizz84
1
bickster

Je viens d'écrire une entrée de blog à ce sujet (en fait, j'ai commencé un blog parce que je pensais que c'était un sujet intéressant). J'ai fini par utiliser la méthode swizzling pour pouvoir appeler le gestionnaire d'achèvement à l'aide des arguments de mon choix sans attendre, ce qui semblait bien pour les tests unitaires. Quelque chose comme ça:

- (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. 
}

entrée de blog pour quiconque s'en soucie.

1
akiraspeirs
0
Ilker Baltaci

Ma réponse est que le test unitaire, conceptuellement, ne convient pas pour tester les opérations asynchrones. Une opération asynchrone, telle qu'une demande au serveur et le traitement de la réponse, ne se produit pas dans une unité, mais dans deux unités.

Pour associer la réponse à la demande, vous devez soit bloquer l’exécution entre les deux unités, soit gérer des données globales. Si vous bloquez l'exécution, votre programme ne s'exécutera pas normalement et si vous conservez des données globales, vous avez ajouté des fonctionnalités superflues pouvant elles-mêmes contenir des erreurs. L’une ou l’autre solution ne respecte pas l’idée même des tests unitaires et vous oblige à insérer un code de test spécial dans votre application. et ensuite, après vos tests unitaires, vous devrez toujours désactiver votre code de test et effectuer des tests "manuels" à l'ancienne. Le temps et les efforts consacrés aux tests unitaires sont alors au moins en partie perdus.

0
David Casseres