Accueil > Mobile, Outillage > Les tests unitaires pour les applications iOS – Partie 1/2 – OCUnit

Les tests unitaires pour les applications iOS – Partie 1/2 – OCUnit

Les tests unitaires apportent de nombreux avantages au développement logiciel, notamment dans un contexte agile où le changement est récurrent:

  • Gestion du changement facilitée : le code en place a été testé, on a moins de risque de régression
  • Réactivité en cas de régression : les tests correspondants à la régression ne passent plus, les problèmes sont identifiés plus tôt.
  • Documentation des comportements du code :
    • facilite la collaboration entre les développeurs : très utile lorsqu’on doit développer une API pour une autre personne/équipe car le tests spécifient l’API ainsi que les résultats attendus.
    • simplifie les transferts de compétence car les tests décrivent le fonctionnement du code
  • Réduction du temps passé à débugger : les tests en échec permettent d’isoler plus rapidement le code fautif

Cette suite d’articles aura donc pour objectif de vous présenter plusieurs frameworks permettant d’atteindre ces objectifs. Le premier article que voici sera centré sur OCUnit.

Présentation

OCUnit est un framework de tests unitaires anciennement appelé SenTestingKit. Il est intégré dans Xcode depuis la version 4, ce qui le rend facile à ajouter à un projet. De plus, cette intégration permet  maintenant de debugger les tests unitaire de la même manière que le code applicatif, ce qui n’était pas le cas avec les versions précédentes de Xcode.

OCUnit utilise l’introspection pour détecter les tests au sein du projet. Il commence par détecter les « build targets » de tests, puis les classes qui héritent de SenTestCase. Ensuite, il exécute les méthodes dont le nom commence par « test ». Chacune de ces méthodes se termine par un ensemble de vérifications via un mécanisme d’assertions relativement semblable à celui de jUnit. De cette manière il est relativement simple d’ajouter des tests au code existant, puisqu’il n’est pas nécessaire de maintenir une liste des tests existants.

La liste complète des macros d’assertions est disponible sur le site d’Apple : http://developer.apple.com/library/mac/#documentation/developertools/Conceptual/UnitTesting/AB-Unit-Test_Result_Macro_Reference/result_macro_reference.html

Une bonne pratique consiste à créer une classe qui hérite de SenTestCase pour chaque classe que l’on veut tester. Par exemple, pour tester la classe « MyClass », on créera une classe « MyClassTests » qui contiendra les tests correspondants. Il est aussi grandement conseillé qu’une méthode de test ne contienne qu’un nombre minimal d’assertions, une seule dans l’idéal. Ceci permet de définir un découpage relativement fin des vérifications effectuées et facilite l’identification des problèmes lorsque le résultat/comportement obtenu n’est pas celui attendu.

Application Tests v.s. Logic Tests

Apple distingue 2 catégories de tests OCUnit :

  • Logic Tests
  • Application Tests

Les Logic Tests

Les « Logic Tests » sont ce que l’on considère comme des tests unitaires « traditionnels ». Il permettent de tester le comportement du code en restant isolé du reste de l’application, par exemple un algorithme, un view controller, un data model, etc.

Ces tests ne peuvent être exécutés que sur le simulateur et ont pour avantage principal d’être très rapides à lancer et exécuter, avantage nécessaire si on veut pouvoir les exécuter à chaque build de l’application. Pour cela, on ajoutera la target des Logic Tests en tant que « target dependency » de la target de l’application.

Attention tout de même : si on build l’application pour un device, les Logic Tests seront compilés automatiquement mais ils ne seront pas exécutés, ces derniers  ne pouvant être exécutés que sur le simulateur !

Pour ajouter des Logic Tests à un projet, il faut sélectionner le fichier du projet dans le Project Navigator de Xcode, cliquer sur le bouton « Add target » et choisir l’option « Other > Cocoa Touch Unit Testing Bundle ».

N.B. Ces tests étant exécutés en dehors de l’application, il est nécessaire d’ajouter toutes les classes référencées par les tests à la target nouvellement créée, dans la section « Compile Sources ».

Exemple

Imaginons une classe Location.m qui suit la position géographique de l’utilisateur, récupère le code postal correspondant et calcule sa vitesse de déplacement. Pour les besoins de cet article et par soucis de concision, nous allons nous focaliser sur le code de la méthode d’initialisation d’une instance pour écrire nos tests. Bien évidemment, en conditions réelles, les tests unitaires ne se limitent pas à cette seule méthode.

<< Location.h >>

#import "Foundation/Foundation.h"
#import "CoreLocation/CoreLocation.h"

@interface Location : NSObject

@property (nonatomic, strong) CLLocationManager *locationManager;
@property float speed;
@property (nonatomic, strong) NSString *postalCode;
@property (nonatomic, strong) CLGeocoder *geocoder;
@property (nonatomic, strong) NSString *speedText;

@end

<< Location.m >>

#import "Location.h"
#import "LocationForTesting.h"

@implementation Location

@synthesize locationManager = _locationManager;
@synthesize speed = _speed;
@synthesize postalCode = _postalCode;
@synthesize geocoder = _geocoder;
@synthesize geocodePending = _geocodePending;
@synthesize speedText = _speedText;

[...]

- (id)init {
    self = [super init];
    if (!self) {
        return nil;
    }
    _postalCode = @"Unknown";
    _geocodePending = NO;
    _geocoder = [[CLGeocoder alloc] init];
    _speedText = @"Calculating..";
    _locationManager = [[CLLocationManager alloc] init];
    _locationManager.delegate = self;
    _locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation;
    _locationManager.distanceFilter = kCLDistanceFilterNone;

    return self;
}

[...]

@end

Pour cette classe, nous allons pouvoir écrire des tests unitaires afin de s’assurer que la méthode « init » initialise notre future instance comme nous le souhaitons.

Pour cela, nous allons créer une nouvelle classe dans groupe « LogicTests » du Project Navigator de notre projet. Celle-ci s’intitulera « LocationTests.m ». En effet, une bonne pratique consiste à réutiliser le nom de la classe à tester et de le suffixer par « Test ». Ceci simplifie l’organisation des fichiers.

De plus, nous n’aurons pas besoin du fichier LocationTests.h, donc nous pouvons le supprimer et rajouter la déclaration de l’interface en haut du fichier LocationTests.m comme ci-dessous.

Pour effectuer ces tests, nous allons avoir besoin d’une instance de la classe Location, donc il nous faut une property qui va nous permettre de référencer cette instance.  Cette property est initialisée dans la méthode setUp() et détruite dans la méthode tearDown(). De cette manière, on a la certitude qu’une nouvelle instance sera créée avant chaque test, puis détruite ensuite. Ce point est essentiel, car les tests unitaires sont par nature indépendants les uns des autres. Dans le cas contraire, un test pourrait affecter le résultat obtenu dans un autre test !

De cette manière, notre premier test peut consister à vérifier que l’instance de Location est bien créée. C’est ce qui est effectué par la méthode testInit.

<< LocationTests.m >>

#import "LocationTests.h"
#import "Location.h"
#import <SenTestingKit/SenTestingKit.h>
  
@interface LocationTests : SenTestCase

@property (nonatomic, strong) Location *location;

@end


@implementation LocationTests 

@synthesize location = _location;

- (void)setUp {
     [super setUp];
     // Set-up code here.
     self.location = [[Location alloc] init]; 
} 

- (void)tearDown {
     // Tear-down code here.
     self.location = nil;
     [super tearDown]; 
}

- (void)testInit {
     STAssertNotNil(self.location, @ »Test object not created »); 
}

@end

Dans le code ci-dessus, j’ai rajouté un premier test nommé « testInit ». Celui-ci permet de s’assurer que notre instance de test est bien créée dans la méthode « setUp ». Il consiste à vérifier que la property self.location ne soit pas nulle.

Pour les tests suivants, une technique relativement simple consiste à parcourir la méthode à tester, instruction par instruction et d’écrire une méthode de test correspondant à chacune d’elles. Si nous étions dans une optique TDD (Test-Driven Development), nous aurions suivi le cheminement inverse et commencé par écrire un test pour chaque comportement attendu. Les tests en questions sont présentés ci dessous:

- (void)testThatInitSetsPostalCode {
    NSString *postalCode = _location.postalCode;
    STAssertTrue([postalCode isEqualToString:@"Unknown"], @"PostalCode should be Unknown but is %@", postalCode);
}

- (void)testThatInitSetsGeocodePendingNo {
     STAssertFalse(_location.geocodePending, @"GeocodePending should be NO"); 
}   

- (void)testThatInitSetsGeocoder {
     STAssertNotNil(_location.geocoder, @"geocoder is not set"); 
}

- (void) testThatInitSetsLocationManager {
     STAssertNotNil(self.location.locationManager, @"locationManager property is nil");
     STAssertTrue([self.location.locationManager isKindOfClass:[CLLocationManager class]] , @"locationManager class should be CLLocationManager");
}

- (void)testThatInitSetsLocationManagerDelegate {
     STAssertTrue(_location.locationManager.delegate == _location, @"LocationManager's delegate should be location object");
}

- (void)testThatInitSetsLocationManagerProperties {          
     STAssertEquals(_location.locationManager.desiredAccuracy, kCLLocationAccuracyBestForNavigation, @"LocationManager desiredAccuracy not set properly");
     STAssertEquals(_location.locationManager.distanceFilter, kCLDistanceFilterNone, @"LocationManager distanceFilter not set properly");
}

Dans les tests présentés ici, vous pouvez remarquer qu’ils contiennent tous au moins une assertion. Chacune de ces assertions est associée à un message d’erreur qui sera présenté dans le cas ou les tests de passent pas. La pertinence de ces messages d’erreur est capitale pour que les tests unitaires aient une valeur ajoutée réelle. Une bonne pratique consiste à y inclure le résultat attendu. Dans les cas complexes, il est aussi recommandé de pousser le détail jusqu’à afficher aussi le résultat obtenu.

De cette manière, quelqu’un qui reprend le code peut facilement identifier le comportement/résultat attendu. En cas de régression, il sera de la même manière plus aisé d’isoler les problèmes.

Comme nous venons de le voir, les Logic Tests permettent de tester du code isolé du reste de l’application afin d’en garantir le bon fonctionnement et de détecter au plus tôt la moindre régression.  Pour ce faire, il est nécessaire de produire des tests de qualité qui couvrent le plus de cas possibles. Dans le cas contraire, on prend le risque d’insérer une régression sans que les tests ne soient impactés.

Les tests unitaires ne sont cependant pas suffisants. En effet, si un code isolé se comporte comme prévu, rien ne garantit qu’il va en être de même une fois intégré au sein une application.

Les Application Tests

Les « Application Tests » sont plus proches des tests d’intégration. Ils sont concrètement exécutés dans le contexte de l’application, par injection au runtime. De ce fait, leur exécution est plus lente (temps de déploiement + lancement de l’application) mais permettent de vérifier des aspects qui sont hors de portée des Logic Tests. Il est par exemple possible de vérifier que les IBOutlets des contrôleurs sont bien connectés aux vues, que nos vues sont bien configurées ainsi que tous les problèmes liées au device, tels que la géolocalisation ou encore les Low Memory Warnings.

Ces tests peuvent être exécutés a la fois sur le simulateur et sur device et ont pour avantage principal de pouvoir référencer tout objet qui appartient au bundle de l’application.

Attention tout de même : si on build l’application en ligne de commande, par exemple dans le cadre d’une intégration continue, le script d’exécution des tests semble rencontrer des problèmes et indique que les « Application-hosted tests » ne sont pas supportés, contrairement à ce qui est indiqué dans la documentation d’Apple. Ce problème semble présent dans les versions récentes de Xcode. Les différents workarounds proposés par la communauté impliquent de lourds changements dans les scripts de Xcode et deviennent souvent caduques à chaque mise à jour. Si vous comptez ajouter ce genre de tests au sein d’une démarche d’intégration continue, il est important de garder ce point en tête.

Pour ajouter des Application Tests à un projet, le plus simple est de cocher la case « Include Unit Tests » lors de sa créationIl est aussi possible d’en intégrer à un projet existant en ajoutant une target comme pour les Logic Tests, moyennant quelques manipulations supplémentaires (Bundle Loader et Test Host) comme indiqué dans la documentation d’Apple.

N.B. Il n’est pas possible d’ajouter à la fois des Logic Tests et des Application Test au sein d’une même target.

Exemple

Reprenons notre exemple d’application qui suit les déplacements de l’utilisateur. Pour afficher ces informations, nous utilisons une vue qui présente la position géographique sur une carte ainsi que la vitesse instantanée du déplacement. Cette vue est ici gérée par un contrôleur dont la classe est « ViewController ». Nos Application Tests peuvent par exemple ici consister à vérifier que l’application initialise correctement sa vue au lancement.

Voici un extrait simplifié de la déclaration de la classe ViewController :

<< ViewController.h >>

#import <UIKit/UIKit.h>
#import <MapKit/MapKit.h>
#import "Location.h"

@interface ViewController : UIViewController

@property (weak, nonatomic) IBOutletMKMapView *mapView;
@property (weak, nonatomic) IBOutletUILabel *speedLabel;

[...]

@end

Pour tester notre application, au demeurant relativement simple pour les besoins de l’article, nous allons nous concentrer sur le contrôleur de vue racine, i.e. notre instance de la classe ViewController. Pour ce faire, nous allons créer une classe nommée « ApplicationTests » qui conservera une référence vers notre contrôleur racine comme ci-dessous.

<< ApplicationTests.m >>

#import <SenTestingKit/SenTestingKit.h>
#import "ViewController.h"
#import "AppDelegate.h"

@interface ApplicationTests : SenTestCase

@property (nonatomic, weak) ViewController *viewController;

@end

@implementation ApplicationTests

@synthesize viewController = _viewController;

- (void)setUp {
    [super setUp];

    // Set-up code here.
    UIApplication *application = [UIApplication sharedApplication];
    AppDelegate *appDelegate = [application delegate];
    UIWindow *window = [appDelegate window];
    self.viewController = (ViewController *)[window rootViewController];
}

- (void)tearDown {
    // Tear-down code here.
    self.viewController = nil;

    [super tearDown];
}

- (void)testThatViewControllerIsNotNil {
    STAssertNotNil(self.viewController, @"ViewController is not set");
}

@end

Dans la méthode setUp, nous récupérons une référence vers le rootViewController de l’application. Vous remarquerez ici que le test ne crée pas l’instance de ViewController, mais récupère bien celle créée par l’application au lancement. C’est ici que réside la principale différence vis-à-vis des Logic Tests : les tests sont injectés dans l’application au runtime donc notre contrôleur de vue est géré entièrement par l’application. Dans la méthode tearDown, nous supprimons cette référence, comme nous avons pu le faire avec les Logic Tests. Ici aussi j’en ai profité pour ajouter un test qui vérifie qu’on a bien récupéré une référence « valide » (comprendre non nulle) vers notre instance.  Par extension, nous pourrions aussi créer un test qui s’assure que ce soit bien une instance de la classe ViewController, ce qui peut être utile le jour où l’enchainement des écrans doit évoluer.

Ensuite nous pouvons vérifier que nos IBOutlets sont bien connectés au contrôleur de vue et que la carte (mapView) affiche et suit bien la position de l’utilisateur.

- (void)testThatMapViewIsNotNil {
    STAssertNotNil(self.viewController.mapView, @"MapView is not set");

- (void)testThatMapViewShowsUserLocation {
    STAssertTrue(self.viewController.mapView.showsUserLocation == YES, @"ShowUserLocation is not set");
}

- (void)testThatMapViewUserTrackingFollow {
    STAssertTrue(self.viewController.mapView.userTrackingMode == MKUserTrackingModeFollow, @"UserTrackingMode is not follow");
}

- (void)testThatSpeedLabelOutletIsConnected {
    STAssertNotNil(self.viewController.speedLabel, @"SpeedLabel IBOutlet not connected");
}

Les tests présentés ici sont relativement simples. Ils sont là uniquement pour illustrer l’utilisation des Application Tests. La vérification de la connexion ds IBOutlets peut par exemple paraitre superflue, cependant, dans le cas d’écrans complexes avec beaucoup de composants graphiques ils prennent tout leur sens. En effet, il n’est pas rare de casser un binding lors d’un refactoring ou d’une refonte d’écran et le problème ne saute pas toujours aux yeux. De plus, nous pouvons ici vérifier assez facilement que notre vue est correctement initialisée au lancement de l’application.

Conclusion

Nous venons de voir que le framework OCUnit permet de tester aussi bien du code isolé que son comportement au sein d’une application. Les Logic Tests sont relativement simples à écrire et très rapides à exécuter, contrairement aux Application Tests, plus lents,  qui nécessitent de déployer l’application puis de l’exécuter.

De ce fait, je recommande personnellement de créer des Logic Tests autant que possible et de se rabattre sur les Applications Tests lorsqu’on ne peut pas faire autrement. En réalité, il est parfois nécessaire de simuler d’autres objets dont dépend notre classe testée pour garantir l’aspect unitaire des tests. Pour ce faire nous utiliserons ici le framework OCMock que je vous présenterai dans la 2ème partie à venir.


5 × trois =