Découverte de Mocha
Tester sur le navigateur
Tester avec un Framework
Tests fonctionnels

Dans ce tutoriel je vous propose de découvrir comment tester votre code AngularJS. AngularJS a sa propre manière de gérer l'injection de dépendance ce qui rend les tests difficiles à mettre en place, car il n'est pas possible d'accéder à un service depuis l'extérieur. Heureusement pour nous, AngularJS dispose d'un module ngMock qui permet de gérer cette injection très simplement. Il nous suffira alors d'utiliser ce mock pour injecter les services que l'on souhaite tester.

Installation des outils

Afin de pouvoir écrire nos tests, nous allons installer tous les outils que l'on a vus précédemment

  • karma, pour lancer les tests sur les navigateurs
  • mocha, sera notre framework de test
  • chai nous permettra de gérer les expectations
  • sinon pour les mocks

Ce qui nous donne en une seule ligne

npm i -D chai sinon mocha karma karma-chai-sinon karma-mocha karma-mocha-reporter

Une fois les différents outils installés, il nous suffit d'initialiser Karma avec la configuration souhaitée

karma init

On pensera à modifier la configuration afin d'y intégrer le reporter mocha et le framework chai-sinon

ngMock

Le principal problème que l'on rencontre avec AngularJS est sa manière de gérer les dépendances. En effet, lorsque l'on va créer un service on va lui spécifier les différentes dépendances. AngularJS résoudra alors automatiquement les dépendances en se basant sur le nom de nos paramètres.

angular.controller('MonSuperController', function($scope, service1, service2...){}

Du coup, on aura besoin d'utiliser l'injection d'AngularJS pour nos tests afin qu'il charge de manière automatique les différentes dépendances. On pourra pour cela utiliser ngMock qui nous permettra d'injecter et de mocker les services d'AngularJS. Pour installer ngMock vous pouvez le télécharger directement depuis npm.

npm i -D angular angular-mocks

Et on l'inclura alors dans les fichiers traités par Karma

    files: [
      'node_modules/angular/angular.js',
      'node_modules/angular-mocks/angular-mocks.js',
      'js/*.js',
      'test/**/*.js'
    ],

Tester un service

Les services sont les briques les plus faciles à tester notre application. Contrairement aux controller et aux directives ils peuvent fonctionner de manière complètement isolée et n'ont, en général, que très peu de dépendance. Nous allons imaginer tester un service qui permet de récupérer les nombres de partages d'un lien sur Twitter.

On commence donc par créer notre module AngularJS

var app = angular.module('App', [])

Et on lui injecte ensuite notre service :

app.factory('Social', function($http, $q){

  return {
    twitterAPI: 'http://urls.api.twitter.com/1/urls/count.json?callback=angular.callbacks._0&url=',
    getTwitterCount: function(url){
      q = $q.defer()
      $http.jsonp(this.twitterAPI + url)
      .then(function(result){
        q.resolve(result.data.count)
      })
      return q.promise
    }
  }

})

Afin de tester ce service, on a besoin de créer un mock de notre module et de récupérer notre service. On utilisera pour cela la fonction beforeEach qui nous permettra de lancer un code avant chaque test :

describe('Social', function(){

  var Social;

  beforeEach(function(){
    angular.mock.module('App')
    angular.mock.inject(function(_Social_){
      Social = _Social_
    })
  })

    it("Should have a getTwitterCount method", function(){
     expect(Social.getTwitterCount).to.be.a('function')
    })

});

ngMock permet de faire de l'injection en utilisant les noms entourés d'un _, ceci nous permet de faire la distinction entre la variable locale et globale. En plus de pouvoir injecter les différentes dépendances dont on a besoin pour nos tests il est aussi possible d'injecter des services supplémentaires comme par exemple $httpBackend pour créer de faux appels HTTP.

Dans le cadre de nos tests, nous ne souhaitons pas faire appel à l'API de Twitter afin de tester notre service de manière isolée. On peut alors utiliser $httpBackend afin de fausser les résultats des appels.

describe('Social', function(){

  var Social;
  var $http;
  var url = "http://grafikart.fr";

  beforeEach(function(){
    angular.mock.module('App')
    angular.mock.inject(function(_Social_, $httpBackend){
      Social = _Social_
      $http = $httpBackend
    })
  })

  afterEach(function(){
    $http.verifyNoOutstandingExpectation();
    $http.verifyNoOutstandingRequest();
  })


  it("Should have a getTwitterCount method", function(){
    expect(Social.getTwitterCount).to.be.a('function')
  })

  it("Should call JSONP", function(){
    $http.expectJSONP(Social.twitterAPI + url).respond(false)
    Social.getTwitterCount(url)
    $http.flush()
  })

  it("Should return count", function(){
    count = 0;
    $http.expectJSONP(Social.twitterAPI + url).respond({count: 2})
    Social.getTwitterCount(url).then(function(c){
      count = c
    })
    $http.flush()
    expect(count).to.be.equal(2)
  })


})

Le service $httpBackend nous offre de méthodes supplémentaires qui vont nous permettre de manipuler les requêtes qui seraient effectuées par les services que l'on souhaite tester.

  • .expect(...).respond(...) permet de vérifier qu'une requête est bien effectuée
  • .when(...).respond(...) permet de fausser les résultats de certains appels (si aucun n'appel n'est fait aucune erreur ne sera renvoyée)

Le service $httpBackend dispose d'une méthode flash qui va permettre de libérer les différentes requêtes en attente. Ceci permet d'exécuter notre code de manière synchrone et simplifie grandement l'utilisation des tests, car on a le contrôle sur la résolution des requêtes.

Tester les controllers

Contrairement aux services, les controller sur AngularJS sont conçus pour fonctionner avec de nombreuses dépendances, et ils font souvent la liaison entre les services et le scope. Nous allons pour l'occasion tester un controller relativement simple

app.controller('ImagesCtrl', function($scope, Social){
  $scope.images = []
  $scope.deleteImage = function(image){
    $scope.images.splice($scope.images.indexOf(image), 1)
  }
})

Pour pouvoir utiliser notre controller dans nos tests nous allons avoir besoin du service $controller qui va nous permettre d'initialiser notre controller, mais aussi d'un scope :

describe('ImagesCtrl', function(){

  var scope;
  var controller;

  beforeEach(function(){
    angular.mock.module('App')
    angular.mock.inject(function($controller, $rootScope){
      scope = $rootScope.$new()
      controller = $controller('ImagesCtrl', {
        $scope: scope
      })
    })
  })

  it('Should have images', function(){
    expect(scope.images).to.be.eql([])
  })

  it('Should delete image', function(){
    var image = {}
    scope.images = [{}, image, {}]
    scope.deleteImage(image)
    expect(scope.images.length).to.be.equal(2)
    expect(scope.images[1]).to.not.be.equal(image)
  })

})

Les directives

Les directives sont un élément important et très puissant d'AngularJS mais aussi très complexe quand il faut intervenir de nombreuses notions particulières comme l'isolation du scope, la manipulation du DOM, et la propagation du scope.Comme pour les controller pour tester nos directives nous allons faire appels à un service d'AngularJS : $compile. Ce service permet comme son indique de compiler une directive en lui injectant un scope particulier.

Nous allons tester ici une directive relativement simple qui nous permettra d'afficher différents messages d'alerte.

app.directive('alerts', function(){
  return {
    restrict: 'E',
    template: '<div>' +
      '<div class="alert" ng-repeat="message in messages">{{ message.msg }}</div>' +
    '</div>',
    scope: {
      messages: '=msgs'
    },
    link: function(scope, element, attrs){
      scope.demo = 1
    }
  }
})

Comme pour les controller nous aurons besoin de passer à notre directive un scope et nous utiliserons le service $rootScope pour cela

describe.only('Alert directive', function(){

  var scope;
  var element;

  beforeEach(function(){
    angular.mock.module('App')
    angular.mock.inject(function($compile, $rootScope){
      scope = $rootScope.$new()
      element = $($compile('<alerts msgs="aa"></alerts>')(scope))
      $('body').append(element)
      scope.$digest()
    })
  });

  afterEach(function(){
    element.remove()
  })

  it('Should display alerts', function(){
    scope.aa = [{type: 'success', msg: 'Bravo'}, {type: 'success', msg: 'Bravo'}]
    scope.$digest()
    expect($('.alert', element).length).to.be.equal(2)
    expect($('.alert:first', element).text()).to.be.equal('Bravo')
  })

  it('Should have dmeo in the scope', function(){
    expect(element.isolateScope().demo).to.be.equal(1)
  })

  it('should be visible', function(){
    scope.aa = [{type: 'success', msg: 'Bravo'}]
    scope.$digest()
    expect($('.alert').is(':visible')).to.be.true
  })

});

Plusieurs éléments importants sont à noter lorsque vous souhaitez faire des tests au niveau de vos directives :

  • Si vous souhaitez faire des tests sur la visibilité d'un élément, il vous faudra alors l'ajouter à votre page HTML. En effet un élément qui est stocké en mémoire n'est pas considéré comme visible. Dans ce cas-là, pensez à supprimer l'élément de votre code HTML entre chaque test.
  • Si vous faites des modifications au niveau de la variable scope, ces modifications ne sont pas automatiquement transmises à votre directive, Il faudra alors utiliser la méthode $digest afin que votre scope affecte votre directive.
  • Si votre directive utilisa scope isoler, il faudra alors utiliser la méthode isolateScope() Sur votre élément, pour obtenir le scope de la directive et non pas le scope parent.