Framework für die Erstellung von Modultests mit JavaScript

Jasmine: Unit Tests für Angular-Webapplikationen erstellen
Kommentare

Ein nicht zu vernachlässigender Teil bei der Entwicklung jeder Anwendung ist das Testen. In diesem Artikel soll es um verschiedene Möglichkeiten gehen, Modultests für eine Angular-Webapplikation zu erstellen und diese in verschiedenen Umgebungen laufen zu lassen.

In vielen Softwareprojekten werden aus verschiedensten Gründen häufig nur Integrations- oder End-to-End-Tests durchgeführt. Oft werden die Modultests hinten angestellt oder entfallen aus zeitlichen Gründen ganz. Dieser Artikel soll neben den technischen Möglichkeiten auch anhand von Beispielen zeigen, dass es mit JavaScript relativ einfach ist, eine solide Basis für automatisierte Modultests oder auch eine testgetriebene Entwicklung zu erstellen.

Das Hauptziel von Modultests (engl.: Unit Tests) ist es, die Software in kleinstmögliche selbstständige funktionale Einheiten zu zerlegen, diese von dem restlichen Programm zu isolieren und zu analysieren, ob diese Einheiten sich wie erwartet verhalten.

Angular ist ein MVVM-Framework mit integrierter Dependency Injection, das die Erstellung von Modultests erleichtert und teilweise mit zusätzlichen Bibliotheken und Funktionen unterstützt.

Jasmine

Es gibt verschiedene Bibliotheken und auch Frameworks für die Erstellung von Modultests mit JavaScript. In diesem Artikel soll das Framework Jasmine zur Anwendung kommen. Dieses Framework ist verhältnismäßig weit verbreitet, unterstützt den testgetriebenen Entwicklungsansatz, kann im Zusammenspiel mit jedem anderen Framework verwendet werden und hängt von keinem anderen Framework und keiner anderen Bibliothek ab.

Jasmine kann entweder direkt von der GitHub-Projektseite heruntergeladen oder mithilfe eines CDN-Servers in das jeweilige Projekt eingefügt werden (Listing 1).

Listing 1
<link data-require="jasmine@2.2.1" data-semver="2.2.1" rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.1/jasmine.css" />
<script data-require="jasmine@2.2.1" data-semver="2.2.1" src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.1/jasmine.js"></script>
<script data-require="jasmine@2.2.1" data-semver="2.2.1" src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.1/jasmine-html.js"></script>
<script data-require="jasmine@2.2.1" data-semver="2.2.1" src="http://cdnjs.cloudflare.com/ajax/libs/jasmine/2.2.1/boot.js"></script>

Zur Vereinfachung soll für die Beispiele in diesem Artikel Plunker verwendet werden. Hier steht eine Schablone bereit, von der sich eigene Projekte zum Ausprobieren der Beispiele ableiten lassen.

Erste Schritte

Eine Sammlung bzw. Gruppe von Testfällen mit Jasmine wird mit der Funktion describe erstellt (Listing 2). Diese Funktion hat zwei Argumente, den Namen der Gruppe und eine Funktion, die den Ablauf der Tests bzw. Testschritte innerhalb dieser Gruppe beschreibt. Der eigentliche Test erfolgt innerhalb der Funktion it, die wiederum zwei Parameter hat. Eine kurze Beschreibung des jeweiligen Tests (bzw. Testschrittes) sowie eine Funktion, die den Test selbst beinhaltet. Innerhalb dieser Funktion wird der Programmcode ausgeführt und entsprechende Ergebnisse unter Verwendung von expect erwartet. Dabei können mehrere Erwartungen in einem Test enthalten sein (Abb. 1).

Listing 2
describe("my first test collection", function() {
  it("testing a calculation", function() {
    var x = 4 + 6;
    expect(x).toBe(10);
  });
});
Abb. 1: Ergebnis des Testbeispiels aus Listing 2

Abb. 1: Ergebnis des Testbeispiels aus Listing 2

Eine Sammlung kann wiederum eine untergeordnete Sammlung von weiteren Sammlungen und/oder Testfällen enthalten. Auf diese Weise lassen sich relativ komplexe Szenarien zusammensetzen. Um ein erwartetes Ergebnis prüfen zu können, stellt Jasmine verschiedene Erwartungsbeschreibungsfunktionen bereit. Tabelle 1 zeigt eine Übersicht über die wichtigsten integrierten Erwartungsbeschreibungen.

Tabelle 1: Übersicht der wichtigsten Erwartungsoperatoren von Jasmine

Tabelle 1: Übersicht der wichtigsten Erwartungsoperatoren von Jasmine

Oft müssen Objekte vor jedem Testschritt initialisiert und ggf. nach dem Test zurückgesetzt werden, bevor sich verschiedene Funktionen testen lassen. Jasmine stellt hierfür die zwei Funktionen beforAll und afterAll bereit. Diese werden jeweils vor bzw. nach jedem Test innerhalb der Gruppe aufgerufen. Variablen können dabei entweder im übergeordneten Scope angelegt und initiiert oder mithilfe von this übergeben werden (Listing 3). Da die Initialisierung jedes Mal neu durchlaufen wird, wird eine „Verschmutzung“ der Testdaten bei der Verwendung von this durch einen vorangegangenen Test verhindert. Ein Beispiel hierfür findet sich hier bzw. hier.

Listing 3
describe("my second test case", function() {
  var bar;
  beforeEach(function() {
    this.foo = 42;
    bar = { a : "test", b : 42 }
  });

  it("use 'this' to share state", function() {
    expect(this.foo).toEqual(bar.b);
    this.bar = "test pollution?";
  });

  it("prevents test pollution by using 'this'", function() {
    expect(this.foo).toEqual(42);
    expect(this.bar).toBe(undefined);
  });
});

Verwendung von Jasmine im Zusammenspiel mit Angular

Jasmine und Angular lassen sich sehr gut zusammen verwenden. Im folgenden Beispiel soll ein einfacher Angular-Controller mittels Jasmine getestet werden. Dazu wird die angular-mocksBibliothek von Angular benötigt. Diese stellt diverse Helferfunktionen bereit, die das Testen von Angular-Komponenten wie Controllern, Direktiven, Filtern oder Services erleichtern. Eine Helferfunktion aus dieser Bibliothek ist die Inject-Funktion. Diese kann verwendet werden, um Abhängigkeiten mithilfe des Dependency-Injection-Mechanismus von Angular aufzulösen.

Stellen Sie Ihre Fragen zu diesen oder anderen Themen unseren entwickler.de-Lesern oder beantworten Sie Fragen der anderen Leser.

Im folgenden Beispiel soll ein Angular-Controller modulgetestet werden. Der Controller enthält einen Namen und einen Zähler. Der Zähler wird für jede Änderung des Namens um jeweils eins erhöht. Daraus lassen sich (mindestens) zwei Tests ableiten (Listing 4).

Listing 4
var myApp = angular.module('myApp', []);
// Controller which counts changes to its "name" member
myApp.controller('MyCtrl', ['$scope', function ($scope) {
  $scope.name = 'Matthias';
  $scope.counter = 0;
  $scope.$watch('name', function (newValue, oldValue) {
    $scope.counter = $scope.counter + 1;
  });
}]);

Testfall 1 prüft, ob der Name korrekt initialisiert wurde, Testfall 2 prüft, ob der Zähler korrekt initialisiert wurde und erhöht wird, wenn sich der Name ändert.

Bevor einer dieser Tests ausgeführt werden kann, wird zunächst ein neuer Controller in der beforeAll-Funktion mithilfe der inject-Funktion via Dependency Injection angelegt. Dabei wird die Helferfunktion $controller aus der angular-mocks-Bibliothek verwendet. Ferner wird ein neuer Scope für den Controller benötigt. Dieser lässt sich am einfachsten mit der Funktion $rootScope.$new() erzeugen (Listing 5).

Listing 5
beforeEach(inject(function ($rootScope, $controller) {
  scope = $rootScope.$new();
  controller = $controller('MyCtrl', {
    '$scope': scope
  });
}));

Anschießend können die Testfälle erzeugt werden. Im ersten Test wird überprüft, ob der Name dem erwarteten Wert entspricht. Im zweiten Test wird der Counter überprüft, anschließend der Name geändert, der digest-cycle ausgeführt und der Counter nochmals überprüft (Listing 6).

Listing 6
it('name is set', function () {
  expect(scope.name).toBe('Matthias');
});

it('countes the name changes', function () {
  expect(scope.counter).toBe(0);
  scope.name = 'Pete';
  scope.$digest();
  expect(scope.counter).toBe(1);
});

Neben dem Testen eines Controllers lässt sich auch überprüfen, ob sich die Services verhalten, wie erwartet. Der folgende Service soll mit $http auf einen Server zugreifen. Der Service wird ein Promise zurückgeben, das abhängig vom Ergebnis ein entsprechendes Callback ausführt (Listing 7).

Listing 7
// Controller with dependencies on Angular's $http service and promises
myApp.service('myService', function ($http, $q) {
  // Returns a promise which is resolved if http calls succeeds,
  // otherwise the promise is rejected
  var getHttp = function () {

    var defer = $q.defer();

    // Perform the actual HTTP call with query parameters
    // e.g. GET /someUrl?key=value1
    $http({
      method: 'GET',
      url: '/someUrl',
      headers: {
        'Accept-Language': 'en'
      },
      params: {
        key1: "value1"
      }
    }).
    success(function (data, status, headers, config) {
      // this callback will be called asynchronously
      // when the response is available
      defer.resolve(data);
    }).
    error(function (data, status, headers, config) {
      // called asynchronously if an error occurs
      // or server returns response with an error status.
      defer.reject();
    });

    return defer.promise;
  };
  
  return {
    getHttp : getHttp
  }
  
});

Hierbei kommt eine weitere Funktion der angular-mocks-Bibliothek zur Anwendung. Der Service myService soll mittels $http auf ein HTTP-Backend zugreifen. Für den Test soll der Zugriff auf das Backend gemockt werden, d. h., es wird nicht auf den Server zugegriffen, sondern auf eine simulierte Serverschicht, die von $httpBackend bereitgestellt wird. Zudem werden zwei Spy-Funktionen benötigt, die prüfen, ob diese aufgerufen wurden, oder nicht. Um sicherzustellen, dass alle HTTP-Anforderungen in dem Test bearbeitet wurden, wird nach jedem Test eine entsprechende Überprüfung eingefügt. Daraus ergibt sich eine Initialisierung für den Test, wie sie in Listing 8 zu sehen ist.

Listing 8
describe('myService', function () {

  var $httpBackend,
  expectedUrl = '/someUrl?key1=value1',
  promise,
  successCallback,
  errorCallback,
  httpService;
  
  beforeEach(inject(function (myService, _$httpBackend_) {
    $httpBackend = _$httpBackend_;
    successCallback = jasmine.createSpy();
    errorCallback = jasmine.createSpy();
    httpService = myService;
  }));

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

In dem jeweiligen Test wird ein Zugriff auf den entsprechenden URL erwartet und einmal positiv und einmal negativ beantwortet, anschießend wird überprüft, ob das Promise die entsprechende Callback-Funktion ausgeführt hat (Listing 9).

Listing 9
it('successfully http request resolves the promise', function () {
  var data = '{"translationKey":"translationValue"}';
  expect(httpService).toBeDefined();
  $httpBackend.expectGET(expectedUrl).respond(200, data);
  promise = httpService.getHttp();
  promise.then(successCallback, errorCallback);

  $httpBackend.flush();

  expect(successCallback).toHaveBeenCalledWith(angular.fromJson(data));
  expect(errorCallback).not.toHaveBeenCalled();
});

it('http requests with an error rejects the promise', function () {
  $httpBackend.expectGET(expectedUrl).respond(500, 'Oh no!!');
  promise = httpService.getHttp();
  promise.then(successCallback, errorCallback);

  $httpBackend.flush();

  expect(successCallback).not.toHaveBeenCalled();
  expect(errorCallback).toHaveBeenCalled();
});

Testrunner

Modultests mit Jasmine lassen sich aber nicht nur im Browser ausführen, sondern auch auf der Kommandozeile. Hierfür wird ein Testrunner benötigt, z. B. Karma. Um Karma verwenden zu können, muss zunächst Node.js installiert werden. Anschließend kann Karma lokal installiert werden; die Befehle in dem Anwendungsverzeichnis mit den zu testenden Quellen finden Sie in Listing 10.

Listing 10
# Install Karma:
$ npm install karma --save-dev

# Install plugins that your project needs:
$ npm install karma-jasmine karma-chrome-launcher --save-dev

# Install global command interface:
$ npm install karma-cli -g

Nach erfolgreicher Installation muss der Testrunner mithilfe des integrierten Assistenten mit dem Befehl karma init in dem Verzeichnis mit der Anwendung initialisiert werden (Abb. 2).

Abb. 2: Initialisierung von Karma

Abb. 2: Initialisierung von Karma

Anschießend kann Karma direkt aufgerufen werden, entweder mit dem Befehl karma start, um kontinuierlich im Hintergrund ausgeführt zu werden, oder mit dem Befehl karma start –single-run, um alle Tests einmal zu durchlaufen und das Ergebnis auszugeben (Abb. 3).

Abb. 3:Ausführung von Jasmine-Module-Tests mit Karma

Abb. 3:Ausführung von Jasmine-Module-Tests mit Karma

Zusammenfassung

Auf diese Weise lassen sich unter anderem auch Direktiven, Filter und weitere Angular-Elemente systematisch modultesten. Dieser Artikel sollte vor allem einen keinen Einblick sowie einen Überblick über die wichtigsten Möglichkeiten für Modultests einer JavaScript-Applikation geben.

API Summit 2017

Moderne Web APIs mit Node.js – volle Power im Backend

mit Manuel Rauber (Thinktecture), Sven Kölpin (Open Knowledge)

API First mit Swagger & Co.

mit Thilo Frotscher (Freiberufler)

Aufmacherbild: Jasmine flowers von Shutterstock.com / Urheberrecht: mamita

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -