Mettre en place une couverture de tests minimale sur une application iPhone

le 23/02/2011 par Vincent Daubry
Tags: Software Engineering

Imaginons un projet mobile avec des délais très restreints et une équipe qui n’est pas familière avec la mise en place de tests unitaires sur iPhone/iPad, ça vous rappelle quelque chose ?

Sur ce type de projet les tests unitaires sont souvent quasi inexistants, et les équipes sont axées sur la réalisation plutôt que sur la pérennisation de leurs développements. Pourtant la mise en place d’une couverture minimale de test peut être simple, c’est ce que nous allons voir dans cet article.

Prenons l’exemple d’une application de recherche d'agences immobilières. Une des fonctionnalités consiste à afficher dans une table la liste des agences à 500m alentour par ordre de proximité. Sur ce type de fonctionnalité les évolutions en cours de projets risquent d’être nombreuses :

  • Des évolutions purement techniques, comme des ajustements dans le contrat de service
  • Des évolutions fonctionnelles : ajouter des paramètres à la recherche, enrichir les informations ramenées dans les résultats de la recherche, traiter les cas particuliers découverts en phase de recette, etc

Avoir une couverture de tests dès le départ permettra de détecter immédiatement les régressions et aura donc énormément de valeur tout a long du projet et pour les prochaines versions de l’application.

Avant tout il vous faudra les bons outils, nous avions fait un tour d'horizon des outils disponibles dans un précédent article : Tests unitaires et tests d’interface sur iPhone : État des lieux. Nous partirons donc avec Google Toolbox for Mac comme framework de test. Celui-ci nous apportera l'environnement d'exécution des tests, à la manière de JUnit en Java ou NUnit en .NET

Ou mettre des tests en priorité ?

Pour implémenter cette fonctionnalité de recherche, en général nous aurons :

  1. Une classe de service pour l'accès aux données des agences

  2. La gestion de la table des agences dans le ViewController

Dans ce découpage les tests unitaires seront le plus rentables sur les parties suivantes:

  • Le parsing de la réponse du Web service
  • Les règles d’affichage des résultats

Ce sont en effet les parties du code qui sont le plus susceptibles d’être affectées par les évolutions (et donc d’introduire des régressions).

Ce sont également les méthodes les plus pénibles à débugger :

Il faut lancer l’application dans le simulateur, se déplacer jusqu’à l’écran à tester, mettre l’application dans un état permettant de reproduire le cas de figure, poser un point d’arret, modifier votre code... et recommencer jusqu’à ce qu’on obtienne le comportement attendu !

Développer ces parties en Test Driven Development (TDD) permet très facilement de vérifier le résultat et reproduire des cas limites, sans jamais lancer le simulateur.

Tester l’appel au Web service

Imaginons que le serveur renvoie du JSON, toute la logique de la classe de service se concentre dans la méthode chargée de parser la réponse du Web service et alimenter notre modèle métier. C’est donc celle-ci que nous nous testerons en priorité.

Pour ce faire rien de plus simple : nous utiliserons un fichier contenant un exemple du JSON que l’on doit parser, et votre test devra vérifier qu'avec ce JSON en entrée vous obtenez la bonne grappe d'objet métier en sortie.

Typiquement nous aurons dans notre classe de service une méthode du type :

- (NSArray*) parseString:(NSString*)stringToParse {
	NSMutableArray *results = [NSMutableArray array];

	NSArray *jsonArray = [stringToParse JSONValue];
	for(NSDictionary* dict in jsonArray) {
		Agence *agence = [[Agence alloc] initWithDictionary:dict];
			[results addObject:agence];
		}
	}

	return results;
}

Et voici un exemple des tests qui vérifient son bon fonctionnement :

-(void) testParsingShouldReturn45Agencies {

//récupération du JSON d’exemple
   NSString *jsonSamplePath = [[NSBundle mainBundle] pathForResource:@"json_sample_agencies" ofType:nil];
   NSString *jsonSampleStr = [[[NSString alloc] initWithContentsOfFile:jsonSamplePath encoding:NSUTF8StringEncoding error:nil] autorelease];

// appel de la méthode de parsing
   NSArray *agenceList = [service parseString:jsonSampleStr];

//vérification qu’il y a bien 45 agences dans la liste retournée
   STAssertEquals((uint)45, agenceList.count, nil);
}

-(void) testParsingShouldReturnAnAgencyWithNameDeFranceAndLocalisationParis {

//récupération du JSON d’exemple
   NSString *jsonSamplePath = [[NSBundle mainBundle] pathForResource:@"json_sample_agence_defrance" ofType:nil];
   NSString *jsonSampleStr = [[[NSString alloc] initWithContentsOfFile:jsonSamplePath encoding:NSUTF8StringEncoding error:nil] autorelease];

// appel de la méthode de parsing
   NSArray *agenceList = [service parseString:jsonSampleStr];

//vérification qu’il n’y a bien qu’une agence dans la liste retournée
   STAssertEquals((uint)1, agenceList.count, nil);

//Récupération de la 1ère agence de la liste
   Agence *firstAgence = [agenceList objectAtIndex:0];

//vérification que l’agence a été créé avec les bonnes données
   STAssertEqualStrings(agence.name, @"DeFrance", nil);
   STAssertEqualsWithAccuracy(agence.coordinate.latitude, 48.9686596, 0.01, @"");
   STAssertEqualsWithAccuracy(agence.coordinate.longitude, 2.414733, 0.01, @"");
}

Si votre contrat de service évolue il vous suffira de mettre à jour le fichier JSON avec la nouvelle réponse du serveur, mettre à jour votre modèle métier si nécessaire, et de faire de nouveaux passer vos tests.

L’affichage de la liste des résultats :

On souhaite tester le format d’affichage de la liste des agences dans la UITableView, par exemple que les agences avec un nom trop long sont bien tronquées au milieu, ou que les agences que j'ai mis dans ma liste de favoris ont une couleur particulière. Pour cela on pourrait être tenté de tester directement la méthode tableView:cellForRowAtIndexPath:

Mais cette approche s'avèrera vite pénible : cette méthode prend en paramètre une UITableView qu’il faudra fournir dans vos tests, créer une liste d’agences, et le NSIndexPath correspondant à l’agence que vous voulez tester.

On préférera créer une méthode :

-(UITableViewCell*) cellForAgence:(Agence*)agence

Celle-ci prend en paramètre un objet métier, et retourne une cellule configurée pour afficher cet objet en tenant compte de toutes les règles d’affichage.

Ces paramètres sont simples à créer dans nos tests, et on récupère une cellule sur laquelle il sera tout aussi simple de faire des assertions.

Notre méthode tableView:cellForRowAtIndexPath: devient donc :

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

	Agence *agence = [self.agenceList objectAtIndex:indexPath.row];
	UITableViewCell *cell = [self cellForAgence:agence];

    return cell;
}

Contourner les difficultés :

L'environnement COCOA peut être frustrant lorsqu'on essaye d'appliquer une approche radicalement orientée TDD, de nombreux aspects deviennent vite complexes à tester lorsque l'on débute : exception, multi-threading, etc.

Le forum de Google Tool Box For Mac et stackoverflow apportent souvent l'aide nécessaire sur ce type de problème. Si jamais vous bloquez face à ce type de situation, rappelez vous qu’il est toujours possible de contourner le problème en créant une méthode qui isole la logique que l'on cherche à tester. C'est toujours préférable à laisser l'ensemble de la fonctionnalité sans tests !

Par exemple un problème auquel on est souvent confronté est l’appel à des méthodes de classes sur des objets de Foundation.

Prenons un cas concret :

On veut faire évoluer l’affichage des agences de sorte qu’il faille afficher en vert les agences ouvertes en ce moment, et en rouge les agences actuellement fermées. On pourrait être tenté de récupérer directement la date courante dans notre méthode cellForAgence: via

[NSDate date];

On se retrouve à nouveau dans une situation complexe à tester : à chaque exécution de nos tests la date retournée va changer...

On préférera refactorer la méthode pour qu’elle prenne en paramètre la date :

-(UITableViewCell*) cellForAgence:(agence*)agence date:(NSDate*)date

On peut donc imposer une date dans nos tests et cette méthode est à nouveau très simple à tester.

Conclusion :

Cette approche est issue de compromis et de la recherche de quickwins dans la mise en place de tests unitaires sur un projet iPhone. Pour choisir où placer vos tests en priorité, vous pouvez chercher à identifier :

  • Les méthodes qui sont le plus susceptibles d’introduire des régressions
  • Les méthodes qui fournissent purement de la logique et dont le développement demande sans cesse des aller retour dans le simulateur.

Au fur et à mesure que l'on gagne en expérience on pourra étendre la couverture de test, notamment en introduisant des mock.