2 votes

Tests unitaires Angular : Mocking de plusieurs promesses indépendantes

C'est une longue question, alors je vais commencer par poser la question avec laquelle je me débats :

Comment résoudre des promesses indépendantes pour la même fonction qui a été exécutée avec des paramètres différents dans les tests unitaires, et obtenir des valeurs différentes ?

J'ai des difficultés à simuler un environnement où plusieurs requêtes http sont exécutées, indépendamment les unes des autres, mais avec le même objet de service. Cela fonctionne dans une application réelle, mais la mise en place d'un environnement de simulation approprié pour les tests unitaires (Jasmine, Karma) s'est avérée assez difficile.

Laissez-moi vous expliquer l'environnement, et ce que j'ai essayé de faire :

Tout d'abord, j'ai un contrôleur Angular qui effectue une seule requête http avec un objet de service personnalisé, et la simulation de cette requête dans les tests fonctionne. Ensuite, j'ai créé un contrôleur qui effectue plusieurs requêtes http indépendantes avec le même objet de service, et j'ai essayé d'étendre mes tests unitaires pour couvrir celui-ci, étant donné mon succès avec l'autre contrôleur.

Comment cela fonctionne dans un contrôleur avec une seule requête/promesse :

Si vous ne voulez pas passer par tout cela, vous pouvez sauter directement à Le vrai problème : tester de multiples demandes et promesses indépendantes . Vous devriez probablement.

Commençons par le contrôleur à requête unique et son test de fonctionnement, pour avoir une base.

SingleRequestController

function OpenDataController($scope, myHttpService) {

    $scope.parameterData = {requestString : "A"};
    $scope.executeSingleRequest = function() {
        myHttpService.getServiceData($scope.parameterData)
            .then(function (response) {
                $scope.result = response.data;
            });
    }

    // Assume other methods, that calls on $scope.executeSingleRequest, $scope.parameterData may also change
}

Comme vous l'avez probablement compris, monHttpService est un service personnalisé qui envoie une requête http à une URL donnée, et ajoute les paramètres transmis par le contrôleur.

SingleRequestControllerTest

describe('SingleRequestController', function() {

    var scope, controller, myHttpServiceMock, q, spy;

    beforeEach(module('OppgaveregisteretWebApp'));

    beforeEach(inject(function ($controller, $q, $rootScope, myHttpService) {

        rootScope = $rootScope;
        scope = rootScope.$new();
        q = $q;

        spy = spyOn(myHttpService, 'getServiceData');

        // Following are uncommented if request is executed at intialization
        //myHttpServiceMock= q.defer();
        //spy.and.returnValue(myHttpServiceMock.promise);

        controller = $controller('OpenDataController', {
            $scope: scope,
            httpService: httpService
        });

        // Following are uncommented if request is executed at intialization
        //myHttpServiceMock.resolve({data : "This is a fake response"});
        //scope.$digest();

    }));

    describe('executeSingleRequest()', function () {

        it('should update scope.result after running the service and receive response', function () {

            // Setup example
            scope.parameterdata = {requestString : "A", requestInteger : 64};

            // Prepare mocked promises.
            myHttpServiceMock= q.defer();
            spy.and.returnValue(myHttpServiceMock.promise);

            // Execute method
            scope.executeSingleRequest();

            // Resolve mocked promises
            myHttpServiceMock.resolve({data : "This is a fake response"});
            scope.$digest();

            // Check values
            expect(scope.result).toBe("This is a fake response");
        }); 
    });
});

Il s'agit d'une pseudo-copie légère d'une implémentation réelle sur laquelle je travaille. Il suffit de dire que j'ai, en essayant et en échouant, découvert que pour chaque sur myHttpService.getServiceData (généralement en appelant directement $scope.executeSingleRequest, ou indirectement via d'autres méthodes), il faut procéder comme suit :

  • myHttpServiceMock doit être initialisé à nouveau (myHttpServiceMock= q.defer() ;),
  • initialiser l'espion pour qu'il retourne la promesse simulée (spy.and.returnValue(myHttpServiceMock.promise) ;)
  • Exécuter l'appel au service
  • Résoudre la promesse (myHttpServiceMock.resolve({data : "This is a fake response"}) ;)
  • Appeler digest (q.defer() ;)

Pour l'instant, ça marche. Je sais que ce n'est pas le plus beau code, et pour chaque fois que la promesse fantaisie doit être initialisée et ensuite résolue, une méthode encapsulant ces éléments serait préférable dans chaque test. J'ai choisi de montrer tout cela ici à des fins démonstratives.

Le vrai problème : tester plusieurs demandes et promesses indépendantes :

Supposons maintenant que le contrôleur effectue plusieurs demandes indépendantes au service, avec des paramètres différents. C'est le cas d'un contrôleur similaire dans mon application réelle :

MultipleRequestsController

function OpenDataController($scope, myHttpService) {

    $scope.resultA = "";
    $scope.resultB = "";
    $scope.resultC = "";
    $scope.resultD = "";

    $scope.executeRequest = function(parameterData) {
        myHttpService.getServiceData(parameterData)
            .then(function (response) {
                assignToResultBasedOnType(response, parameterData.requestType);
            });
    }

    $scope.executeMultipleRequestsWithStaticParameters = function(){
        $scope.executeRequest({requestType: "A"});
        $scope.executeRequest({requestType: "B"});
        $scope.executeRequest({requestType: "C"});
        $scope.executeRequest({requestType: "D"});
    };

    function assignToResultBasedOnType(response, type){
        // Assign to response.data to 
        // $scope.resultA, $scope.resultB, 
        // $scope.resultC, or $scope.resultD, 
        // based upon value from type

        // response.data and type should differ,
        // based upon parameter "requestType" in each request
        ...........
    };

    // Assume other methods that may call upon $scope.executeMultipleRequestsWithStaticParameters or $scope.executeRequest
}

Maintenant, je réalise que "assignToResultBasedOnType" n'est peut-être pas la meilleure façon de gérer l'affectation à la bonne propriété, mais c'est ce que nous avons aujourd'hui.

En général, les quatre différentes propriétés de résultat reçoivent le même type d'objet, mais avec un contenu différent, dans l'application réelle. Maintenant, je veux simuler ce comportement dans mon test.

MultipleRequestControllerTest

describe('MultipleRequestsController', function() {

    var scope, controller, myHttpServiceMock, q, spy;

    var lastRequestTypeParameter = [];

    beforeEach(module('OppgaveregisteretWebApp'));

    beforeEach(inject(function ($controller, $q, $rootScope, myHttpService) {

        rootScope = $rootScope;
        scope = rootScope.$new();
        q = $q;

        spy = spyOn(myHttpService, 'getServiceData');

        controller = $controller('OpenDataController', {
            $scope: scope,
            httpService: httpService
        });

    }));

    describe('executeMultipleRequestsWithStaticParameters ()', function () {

        it('should update scope.result after running the service and receive response', function () {

            // Prepare mocked promises.
            myHttpServiceMock= q.defer();
            spy.and.callFake(function (myParam) {
                lastRequestTypeParameter.unshift(myParam.type);
                return skjemaHttpServiceJsonMock.promise;

            // Execute method
            scope.executeMultipleRequestsWithStaticParameters();

            // Resolve mocked promises
            myHttpServiceMock.resolve(createFakeResponseBasedOnParameter(lastRequestTypeParameter.pop()));
            scope.$digest();

            // Check values
            expect(scope.resultA).toBe("U");
            expect(scope.resultB).toBe("X");
            expect(scope.resultC).toBe("Y");
            expect(scope.resultD).toBe("Z");
        }); 
    });

    function createFakeResponseBasedOnParameter(requestType){
        if (requestType==="A"){return {value:"U"}}
        if (requestType==="B"){return {value:"X"}}
        if (requestType==="C"){return {value:"Y"}}
        if (requestType==="D"){return {value:"Z"}}
    };
});

C'est ce qui se passe dans le test (découvert pendant le débogage) : La fonction espionne s'exécute quatre fois, et introduit les valeurs dans le tableau lastRequestTypeParameter, qui sera [D, C, B, A], dont les valeurs sont censées être extraites pour être lues A-B-C-D, afin de refléter l'ordre réel des demandes.

Cependant, voilà le problème : Resolve ne se produit qu'une fois, et la même réponse est créée pour les quatre propriétés de résultat : {valeur : "U"}.

La liste correcte est sélectionnée en interne, car la chaîne de promesses utilise les mêmes valeurs de paramètres que celles utilisées dans l'appel de service (requestType), mais elles ne reçoivent toutes des données que lors de la première réponse. Ainsi, le résultat est :

$scope.resultA = "U" ; $scope.resultB = "U", et ainsi de suite.... au lieu de U, X, Y, Z.

Ainsi, la fonction espionne s'exécute quatre fois, et j'avais supposé que quatre promesses étaient retournées, une pour chaque appel. Mais pour l'instant, il n'y a qu'une seule resolve() et une q.digest(). J'ai essayé ce qui suit, pour que les choses fonctionnent :

  • Four q.defer()
  • Quatre résolus
  • Quatre digests
  • Retourne un tableau avec quatre objets différents, correspondant à ce que j'attends dans le test de travail. (C'est idiot, je sais, cela diffère de la structure d'objet attendue, mais que ne faites-vous pas lorsque vous essayez de modifier quelque chose pour obtenir un résultat surprenant qui fonctionne).

Aucun d'entre eux ne fonctionne. En fait, la première résolution entraîne le même résultat pour les quatre propriétés. L'ajout d'autres résolutions et condensations ne fera donc guère de différence.

J'ai essayé de chercher ce problème sur Google, mais tout ce que je trouve, ce sont des promesses multiples pour différents services, des fonctions en chaîne multiples (.then().then()...), ou des appels asynchrones imbriqués (nouvel(s) objet(s) de promesse à l'intérieur de la chaîne).

Ce dont j'ai besoin, c'est d'une solution pour les promesses indépendantes, créées en exécutant la même fonction avec des paramètres différents.

Je terminerai donc par la question que j'ai posée au début : Comment résoudre des promesses indépendantes pour la même fonction qui a été exécutée avec des paramètres différents dans les tests unitaires, et obtenir des valeurs différentes ?

1voto

estus Points 5252

Jasmine est un outil polyvalent adapté à Angular. Il est généralement adapté à la majorité des cas de tests frontaux. Il manque de fonctionnalités d'espionnage/mocking, alors que Sinon offre beaucoup plus de puissance.

C'est peut-être la raison pour laquelle le paquet modulaire Mocha/Sinon/Chai pourrait être préféré à un moment donné, mais l'avantage de sa modularité est que Sinon n'est pas lié au paquet. En plus de ses relations étroites avec Chai, il peut également être utilisé avec Les allumettes de jasmin .

La chose qui fait de Sinon un meilleur choix que les espions de Jasmine est qu'il est capable de programmer les attentes des espions ( withArgs(...).called... ) et les réponses des stubs ( withArgs(...).returns(...) ). La moquerie des cols bleus devient un jeu d'enfant :

var sandbox;
var spy;

// beforeEach
sandbox = sinon.sandbox.create();
// similar to Jasmine spy without callThrough
spy = sandbox.stub(myHttpService, 'getServiceData');
...

// it
spy.withArgs('A').returns({value:"U"});
spy.withArgs('B').returns({value:"X"});
...

// afterEach
sandbox.restore(); // the thing that Jasmine does automatically for its spies

En ce qui concerne les promesses résolues une fois, c'est le comportement attendu. En règle générale, les promesses fraîches doivent être renvoyées par des fonctions simulées, jamais par un objet existant avec la fonction .returnValue dans Jasmine (ou .returns à Sinon).

Une fonction de rappel doit être utilisée pour renvoyer une nouvelle promesse à chaque appel. Si la promesse doit être résolue avec une valeur prédéfinie, il peut y avoir plusieurs modèles pour y parvenir, le plus évident étant d'utiliser une variable

var mockedPromiseValue;

...
spy = spyOn(myHttpService, 'getServiceData')
  .and.callFake(() => $q.resolve(mockedPromiseValue));
...

mockedPromiseValue = ...;
myHttpService.getServiceData().then((result) => {
  expect(result).toBe(...);
})

// rinse and repeat

$rootScope.$digest();

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X