Auf Nummer sicher gehen

Node.js-APIs mit Tests absichern
Keine Kommentare

Die Node.js-Plattform erfreut sich mittlerweile einer sehr großen Beliebtheit. Die serverseitige JavaScript-Plattform wird nicht nur für Serveranwendungen, sondern auch für zahlreiche Werkzeuge in der Webentwicklung eingesetzt. Gerade in der Microservices-Welt fasst Node.js zunehmend Fuß, spielt hier sehr häufig eine API-Rolle, aggregiert Informationen von anderen Systemen und spielt so seine volle Stärke aus. Dabei sind geringe Fehlerraten über die gesamte Projektlebenszeit enorm wichtig. Wie Sie Ihr Node.js-API mit Tests absichern können, demonstriert dieser Artikel.

Seit Node.js im Jahr 2009 zum ersten Mal in Erscheinung getreten ist, hat die Plattform viele Änderungen erfahren, ihr Kern jedoch wurde stets klein gehalten. So verwundert es auch nicht, dass Node.js vor allem im Verbund mit anderen Systemen seine Stärke ausspielt. Das liegt einerseits an der Flexibilität von JavaScript, zum anderen an einem riesigen Ökosystem, das sich um Node.js gebildet hat. Das npm-Repository bietet für nahezu jede Problemstellung eine Lösung. Und dennoch genießt JavaScript als Grundlage für geschäftskritische Anwendungen nicht den besten Ruf unter Entwicklern. Gerade diejenigen, die viel in typsicheren Sprachen wie Java entwickeln, vermissen Strukturen und Muster in der Entwicklung. Doch für sämtliche dieser Problemstellungen existieren Lösungen, die Sie in Ihre Node.js-Applikation einbinden können. Allen voran sind hier Tests zu nennen. Mit Unit- und End-to-End-Tests können Sie Ihre Node.js-Applikation so absichern, dass Sie eine geringe Fehlerrate über die gesamte Lebenszeit Ihres Projekts erreichen. Für die Erstellung von Tests in Node.js gibt es mehrere Lösungsansätze und Werkzeuge.

Auf den folgenden Seiten werde ich Ihnen zeigen, wie Sie Ihr Node.js-API mit Tests absichern können. Dafür beginnen wir mit den Grundlagen der Testframeworks und kümmern uns dann um Problemstellungen, mit denen Sie bei der Erstellung von APIs sehr häufig konfrontiert werden. Allen voran sind das der Umgang mit Asynchronität, wie sie beispielsweise bei der Abfrage von Remote-Systemen wie anderen Microservices oder Datenbanken auftritt, und der Umgang mit Abhängigkeiten wie beispielsweise Bibliotheken von Drittanbietern.

Testframeworks für Node

Einer der Hauptgründe für den Erfolg von Node.js ist das npm-Repository mit seinen zahllosen Paketen. Arbeiten Sie an einem API, bei dem Sie ein eher exotisches System anbinden müssen und stellen sich dabei die Frage, ob es für die Verbindung oder ein ähnliches Problem in Node.js bereits eine Lösung gibt, lautet die Antwort meistens ja. Für viele Fälle existiert bereits ein npm-Paket, das Sie nur noch installieren und verwenden müssen. Die größte Schwierigkeit hierbei ist, die richtigen Pakete zu identifizieren und sie zu bewerten. Einige der verfügbaren Pakete werden nicht mehr weiterentwickelt und sind deshalb veraltet, oder die Pakete werden nur von wenigen Nutzern verwendet. Diese und noch einige weitere Kriterien spielen bei der Auswahl von Werkzeugen eine entscheidende Rolle. Auch die Codequalität des Pakets und der Umgang der Entwickler mit Fehlern und Featurewünschen ist von Bedeutung. So lohnt es sich immer, einen Blick auf die npm- und GitHub-Statistiken der jeweiligen Pakete zu werfen. Bei den beiden Testframeworks, die ich Ihnen nun vorstellen werde, müssen Sie sich bei den eben vorgestellten Kriterien keine Sorgen machen. Sowohl Jasmine als auch Mocha gehören gewissermaßen zum Mainstream der JavaScript-Testframeworks, sind schon seit Jahren auf dem Markt etabliert und werden selbst von großen Unternehmen zur Qualitätssicherung von JavaScript-Applikationen eingesetzt.

Jasmine

Das Jasmine-Testframework ist der Allrounder unter den Testframeworks. Ursprünglich wurde es für clientseitiges Testen im Browser entwickelt, dann aber recht schnell für den Einsatz mit Node.js adaptiert. Mittlerweile kann es problemlos direkt in Node.js-Applikationen eingesetzt werden. Der Name des zu installierenden Pakets lautet jasmine, das Kommando npm install -D jasmine installiert das Paket. Jasmine bietet Ihnen außerdem einen Assistenten zur Initialisierung des Projekts. Mit jasmine init wird ein Verzeichnis mit dem Namen spec angelegt, in dem Sie Ihre Testdateien ablegen können. Im Unterverzeichnis support liegt die Konfigurationsdatei für Jasmine mit dem Namen jasmine.json. In dieser Datei können Sie beispielsweise angeben, wo die Testdateien zu finden sind und nach welchem Muster die Namen der Testdateien aufgebaut sein sollen. Der Standard für die Benennung der Dateien sieht die Endung .spec.js vor. In Listing 1 sehen Sie ein Beispiel für einen Jasmine-Test, der die wichtigsten Features des Frameworks für Sie zusammenfasst.

const Calculator = require('../src/calc');
describe('Calculator', () => {
  let calculator;
  beforeEach(() => {
    calculator = new Calculator();
  });
  it('should add 1 and 1 and return 2', () => {
    const result = calculator.add(1, 1);
    expect(result).toEqual(2);
  });
});

Jasmine erlaubt die Zusammenfassung einzelner Tests zu Gruppen. Eine solche Testsuite erzeugen Sie mit einem Aufruf der describe-Funktion. Sie können auch mehrere Testsuites ineinander verschachteln, also die describe-Funktion in einer anderen describe-Funktion aufrufen. Für ein solches Vorgehen gibt es zwei mögliche Gründe. Eine Testsuite gruppiert stets zusammengehörige Tests, Ihre Teststruktur wird damit aussagekräftiger, und Sie können durch das erste Argument, das Sie der Funktion mitgeben, den Zweck dieser Testgruppe beschreiben. Der zweite Grund für die Verwendung verschachtelter Testsuites ist, dass die Set-up- und Tear-down-Routinen ebenfalls verkettet werden. Die beforeEach– und afterEach-Funktionen werden vor beziehungsweise nach jedem Test der aktuellen Testsuite ausgeführt. Zusätzlich zu diesen Hooks können Sie auf die beforeAll– und afterAll-Funktionen zugreifen, die einmal vor beziehungsweise nach allen Tests der Suite ausgeführt werden. Bei verschachtelten Suites wird zunächst die beforeAll-Funktion der äußeren Suite ausgeführt, danach folgt die innere beforeAll-Funktion. Nach deren Abarbeitung führt Jasmine die äußere und innere beforeEach-Funktion aus, um Set-up-Funktionalität wie die Initialisierung von Objekten vorzunehmen. Schließlich wird der eigentliche Test abgearbeitet. Für verschiedene Aufräumaufgaben werden dann die innere und äußere afterEach-Funktion und schließlich die beiden afterAll-Funktionen ausgeführt.

Ein Jasmine-Test wird durch den Aufruf der it-Methode eingeleitet. Diese enthält zunächst die Beschreibung des Testfalls und als zweites Argument den Test als Callback-Funktion. Der Aufbau eines Tests folgt immer dem gleichen Schema, er wird als Triple-A bezeichnet. Zunächst bereiten Sie im Arrange-Schritt die Testausführung vor, initialisieren die Umgebung und erzeugen die Objekte, die Sie testen wollen. Dieser Schritt wird sehr häufig in die Set-up-Routinen ausgelagert, um Duplikate im Code zu vermeiden. Ist die Testumgebung bereit, folgt der nächste Schritt, Act. Hier führen Sie die Routine aus, deren Ergebnis Sie überprüfen wollen. Meist wird das Ergebnis in einer Variable zwischengespeichert. Im letzten Schritt, Assert, prüfen Sie schließlich, ob das Ergebnis Ihren Erwartungen entspricht. Dafür stellt Ihnen Jasmine eine Reihe sogenannter Matcher zur Verfügung. Eine Assertion weist immer den gleichen Aufbau auf. Zunächst rufen Sie die expect-Methode mit dem zu überprüfenden Wert auf. Von ihr erhalten Sie ein Objekt als Rückgabewert, das die verschiedenen Matcher implementiert. Auf ihm können Sie dann beispielsweise den toEqual-Matcher verwenden.

Das Jasmine-Framework weist eine Reihe von Vorteilen auf, die es zu einer guten Wahl für das Testen von Node.js-Applikationen und -APIs machen:

  • Es enthält bereits alle notwendigen Bestandteile, die Sie zur Erzeugung von Tests benötigen. In der Regel müssen Sie keine weiteren Pakete installieren.
  • Es ist sowohl client- als auch serverseitig einsetzbar. Entwickler müssen sich also nicht erst an eine neue Syntax gewöhnen.
  • Es ist sehr weit verbreitet und hat eine große Community, die neue Features und Bugfixes beisteuert.
  • Es eignet sich sowohl für den Einsatz in kleinen als auch in großen Projekten, da es sehr wenig Overhead verursacht.
  • Sollte der Funktionsumfang für Sie nicht ausreichen, kann Jasmine vor allem im Bereich der Matcher sehr gut erweitert werden.

Es gibt jedoch nicht nur Vorteile im Zusammenhang mit dem Jasmine-Testframework:

  • Jasmine arbeitet mit global definierten Funktionen, was zu Namenskonflikten führen kann.
  • Da das Framework bereits alle wichtigen Elemente beinhaltet, sind Änderungen und Anpassungen im Kern nur in einem recht engen Rahmen möglich.

Eine vielseitige Alternative zu Jasmine stellt das Testframework Mocha dar.

Mocha

Mocha ist, wie auch Jasmine, ein etabliertes Framework, das bereits seit mehreren Jahren auch in großen Projekten eingesetzt wird. Mocha und Jasmine weisen zahlreiche Gemeinsamkeiten auf, die schon mit der Installation und der Ausführung beginnen. So installieren Sie Mocha in Ihrer Applikation mit dem Kommando npm install -D mocha.

Im grundsätzlichen Aufbau und der verwendeten Syntax unterscheiden sich Jasmine und Mocha kaum. Auch in Mocha erstellen Sie Testsuites mit der describe-Funktion und Tests mit it. Der einzige Unterschied ist, dass die beforeAll– und afterAll-Funktionen in Mocha nur before und after heißen. Auch der Aufbau der Tests folgt der Triple-A-Regel.

Einer der bedeutendsten Unterschiede von Mocha und Jasmine liegt darin, dass Mocha selbst einen relativ geringen Funktionsumfang bietet und Sie daher zusätzliche Pakete installieren müssen. So verfügt Mocha über keine eigene Assertion-Lösung, und auch für den Umgang mit Test-Doubles müssen Sie eine zusätzliche Bibliothek installieren. Was zunächst nach mehr Arbeit und einem Nachteil klingt, bietet Ihnen aber Flexibilität.

Für die Formulierung von Assertions können Sie auf verschiedene Bibliotheken zurückgreifen. Die wichtigsten sind das Node.js-eigene Assert-Modul und die Bibliotheken Should.js, Expect.js und Chai:

  • js verfolgt den BDD-Style, bei dem Sie eine Assertion in einer annähernd natürlichsprachlichen Art formulieren: result.should.not.be(null).
  • js bietet eine ähnliche Syntax wie Jasmine: expect(5)to.eql(5).
  • Chai schließlich bietet die größte Vielfalt an möglichen Assertions, indem es sowohl die Should- als auch die Expect-Syntax unterstützt. Zusätzlich können Sie mit Chai auch die von Node.js bekannte Assert-Syntax verwenden.

Auch wenn es um Test-Doubles wie Spys, Stubs und Mocks geht, müssen Sie bei Mocha ein zusätzliches Paket wie beispielsweise Sinon.js installieren.

const Calculator = require('../src/calc');
const expect = require('chai').expect;
describe('PasswordGenerator', function() {
  let calculator;
  beforeEach(function() {
    calculator = new calculator();
  });
  it('should add 1 and 1 and return 2', function() {
    const result = calculator.add(1, 1);
    expect(result).to.equal(2);
  });
});

Was bei diesem Mocha-Test auffällt, ist, dass die Assertion Library, in diesem Fall Chai, separat über das Node.js-Modulsystem geladen werden muss. Der übrige Quellcode ähnelt dem Jasmine-Beispiel. Auch die Abarbeitung der Hooks und das Ergebnis des Tests sind die gleichen. Die Vor- und Nachteile von Mocha sind annähernd die gleichen wie von Jasmine. Der einzige Unterschied ist, dass Mocha keine eigenen Assertion- und Test-Double-Bibliotheken mitbringt.

Im Verlauf dieses Artikels wird Mocha zur Formulierung der Tests verwendet. Sämtliche Beispiele können mit geringen Anpassungen allerdings auch mit Jasmine nachvollzogen werden.

Best Practices im Umgang mit Tests unter Node.js
Eine der wichtigsten Anforderungen an ein API ist, dass es auch über längere Zeit wart- und erweiterbar bleiben soll. Daher sollten Sie bei der Entwicklung einer solchen Node.js-Applikation eine Reihe von

Konventionen und Best Practices beachten.

In einer Node.js-Applikation werden die mit npm installierten Abhängigkeiten mit ihrem Namen und der zu installierenden Versionsnummer in die package.json-Datei geschrieben. Das dient vor allem der Dokumentation, aber auch dazu, den Stand Ihrer Software bei der Installation korrekt initialisieren zu können, da Sie die npm-Pakete nicht mit in Ihr Sourcecode-Repository einchecken sollten. Bei allen Paketen, die nicht direkt mit der Ausführung der Applikation zu tun haben, wird empfohlen, dass Sie diese als devDependency installieren. Sie müssen bei der Installation lediglich die Option —save-dev oder -D übergeben. Danach haben Sie die Wahl, diese Abhängigkeiten bei einer Installation Ihrer Applikation mit zu installieren oder sie im Fall eines Produktivreleases wegzulassen.

Egal, ob Sie sich für Jasmine oder Mocha als Testframework entscheiden, sollten Sie einige Best Practices im Umgang mit Tests beachten. Zunächst empfiehlt es sich, das jeweilige Testframework lokal für Ihr Projekt zu installieren und nicht systemweit. Damit haben Sie die Möglichkeit, verschiedene Versionen der Frameworks auf Ihrem System zu installieren. Jedes installierte npm-Paket, das über eine ausführbare Datei verfügt, installiert diese unter node_modules/.bin/ in Ihrem Projekt. Sie können diese Kommandos dann entweder direkt oder mithilfe des npx-Kommandos aufrufen. So führen Sie beispielsweise Ihre Mocha-Tests mit npx mocha aus.

npm ist in der Lage, eine Reihe von Scripts für Ihre Applikation auszuführen. Neben beliebig gewählten Namen fallen darunter auch eine Reihe von Standard-Scripts wie zum Beispiel start, install oder test. Jedes dieser Standard-Scripts erfüllt einen bestimmten Zweck für die Applikation. Das Test-Script führt beispielsweise die Tests der Applikation aus. Sie müssen zunächst in Ihrer Package.json-Datei unter dem Schlüssel scripts die Eigenschaft test bearbeiten. Haben Sie Ihre Applikation mit npm init initialisiert, existiert dieser Schlüssel bereits und ist schon vorbelegt. Verwenden Sie Mocha als Testframework, tragen Sie hier als Wert einfach mocha ein. Die npm-Scripts haben das Verzeichnis node_modules/.bin automatisch in ihrem Suchpfad, sodass Sie dieses Kommando direkt ausführen können.

Wo liegen die Testdateien?

Entwickeln Sie ein API mit Node.js, ist es von entscheidender Bedeutung, dass es intern so strukturiert ist, dass Sie und Ihre Kollegen sich schnell im Dateisystem zurechtfinden und die einzelnen Bestandteile der Applikation schnell lokalisieren können. Beim Speichern der Testdateien sollten Sie grundsätzlich zwischen zwei Fällen unterscheiden. Unit-Tests können einfach bestimmten Dateien zugeordnet werden, da sie meist auf Basis einzelner Funktionen arbeiten. End-to-End-Tests, die ganze Features oder Workflows überprüfen, können wiederum in den seltensten Fällen bestimmten Funktionen, Klassen oder Dateien zugeordnet werden. Diese Integrationstests werden in der Regel in einem separaten Verzeichnis mit dem Namen test abgelegt. Verfügt Ihre Applikation über eine größere Anzahl solcher Integrationstests, ist es sinnvoll, die Testdateien in Unterverzeichnisse zu strukturieren.

Bei der Platzierung von Unit-Tests haben sich in der JavaScript-Community zwei Lager gebildet. Die einen vertreten die Auffassung, dass die Tests bei den Dateien gespeichert werden sollten, die von ihnen getestet werden. Die Vertreter der zweiten Variante speichern ihre Tests in einer parallelen Verzeichnisstruktur, die die Struktur der Applikation spiegelt. In den meisten Node.js-Applikationen wird die zweite Variante, also das Speichern der Tests in einem separaten Verzeichnis, verwendet. Wie so oft in der Webentwicklung ist es auch hier Geschmackssache, wie man seine Applikation strukturiert. Wichtig ist lediglich, dass Sie dabei konsistent bleiben und nicht zwischen beiden Varianten hin und her wechseln.

Das Node.js-Modulsystem

Für das Laden von Dateien in Ihrem API verwenden Sie das Node.js-Modulsystem, das auf dem CommonJS-Standard aufbaut. Dieses kommt auch bei der Erstellung von Tests zum Einsatz, um dem Testframework zu signalisieren, welche Funktionalität getestet werden soll. Als Beispiel für ein Stück Businesslogik, wie sie üblicherweise in API-Services vorkommt, enthält Listing 3 eine einfache Klasse, die ein zufälliges Passwort generieren soll.

class PasswordGenerator {
  toString() {
    return 'aaaaaaaa';
  }
}
module.exports = PasswordGenerator;

Zunächst erzeugt der Passwortgenerator eine statische Zeichenkette. Dieses Verhalten wird nach und nach erweitert, sodass der Passwortgenerator schließlich ein konfigurierbares Zufallspasswort erzeugt. Damit die PasswordGenerator-Klasse im Test verwendet werden kann, muss sie zunächst per require eingebunden werden. Danach folgt der Test dem gewohnten Aufbau. Den ersten Test finden Sie in Listing 4.

const PasswordGenerator = require('../src/index');
const expect = require('chai').expect;
describe('PasswordGenerator', function() {
  let pg;
  beforeEach(function() {
    pg = new PasswordGenerator(8);
  });
  it('should create a password', function() {
    const pw = pg.toString();
    expect(pw).to.equal('aaaaaaaa');
  });
});

Test-Doubles

Wie schon erwähnt, soll der Passwortgenerator ein zufälliges Passwort erzeugen. Zufälle sind jedoch der Feind eines jeden Tests. Ein Test läuft in einer kontrollierten Umgebung ab und liefert bei jedem Durchlauf das gleiche Ergebnis. Listing 5 enthält die erweiterte Version des Passwortgenerators. Sie erzeugt ein Passwort, das aus einer konfigurierbaren Anzahl Kleinbuchstaben besteht.

class PasswordGenerator {
  constructor(length = 8) {
    this.password = [];
    this.length = length;
  }
  init() {
    for (let i = 0; i < this.length; i++) {
      const char = Math.floor(Math.random() * 26) + 97;
      this.password[i] = String.fromCharCode(char);
    }
  }
  toString() {
    if (this.password.length === 0) {
      this.init();
    }
    return this.password.join('');
  }
}
module.exports = PasswordGenerator;

Zur Generierung des Passworts wird Math.random verwendet. Diese Methode gibt eine zufällige Zahl zwischen 0 und 1 zurück. Wollen Sie nun den Passwortgenerator testen, müssen Sie den Zufall aus dem Spiel nehmen. Konkret bedeutet das, dass Sie Math.random durch etwas ersetzen müssen, das Sie unter Kontrolle haben. Am einfachsten erreichen Sie das, indem Sie einen sogenannten Stub einsetzen. Stubs sind Funktionen, die ein vorherbestimmtes Verhalten aufweisen. In unserem Fall gibt der Stub für Math.random immer die Zahl 0 zurück. Theoretisch könnten Sie einen solchen Stub auch selbst implementieren, jedoch gibt es bereits etablierte Lösungen zum Umgang mit Test-Doubles. Für Mocha können Sie SinonJS einsetzen. Jasmine implementiert Test-Doubles selbst. Die Wirkung ist in beiden Fällen die gleiche. Es wird eine Wrapper-Funktion erzeugt, die sich um die eigentliche Funktion legt und stattdessen aufgerufen wird. Listing 6 enthält den Test für den Passwortgenerator.

const PasswordGenerator = require('../src/index');
const expect = require('chai').expect;
const sinon = require('sinon');
describe('PasswordGenerator', function() {
  let pg;
  before(function() {
    sinon.stub(Math, 'random').returns(0);
  });
  beforeEach(function() {
    pg = new PasswordGenerator(8);
  });
  it('should create a random password', function() {
    const pw = pg.toString();
    expect(pw).to.equal('aaaaaaaa');
  });
});

Da Math.random nun immer den Wert 0 zurückgibt, lautet das Passwort bei jedem Testdurchlauf aaaaaaaa. Es gibt zwei Gründe, warum in diesem Fall ein Stub eingesetzt wird. Ohne den Stub könnten Sie die Passwortgenerierung zwar auch testen, allerdings etwas aufwendiger, indem Sie einen regulären Ausdruck definieren, der sicherstellt, dass das Passwort aus acht Kleinbuchstaben besteht. Je mehr Features Sie in den Passwortgenerator integrieren, desto komplexer wird der erforderliche reguläre Ausdruck. Kontrollieren Sie das System selbst, können Sie stets vom gleichen Ergebnis ausgehen. Der zweite Grund für den Stub ist, dass Sie nicht die Funktionalität von Math.random testen möchten, sondern nur, ob der Passwortgenerator das tut, was er soll. Sie klammern also gezielt die Teile des Systems aus, die Sie nicht testen möchten.

Angular Kickstart: von 0 auf 100

mit Christian Liebel (Thinktecture AG) und Peter Müller (Freelancer)

JavaScript für Softwareentwickler – für Einsteiger und Umsteiger

mit Yara Mayer (evia) und Sebastian Springer (MaibornWolff)

In Node.js-Applikationen kommen sehr häufig Callback-Funktionen zum Einsatz. Gerade, wenn es um asynchrone Operationen geht, in denen Sie nicht mehr direkt auf einen Rückgabewert zurückgreifen können. In diesem Fall bietet SinonJS mit Spys ein weiteres Hilfsmittel. Ein Spy ist ebenfalls eine Wrapper-Funktion, die allerdings die Aufrufe auf die ursprüngliche Funktion lediglich aufnimmt, damit sie später überprüft werden können. Mit einem Spy können Sie auf sämtliche Argumente, Rückgabewerte und geworfene Exceptions zugreifen. Implementieren Sie nun eine Version des Passwortgenerators, die mit Callbacks arbeitet, würde der Test wie in Listing 7 aussehen.

it('should support callbacks', function() {
  const spy = sinon.spy();
  pg.getPassword(spy);
  expect(spy.calledWith(null, 'aaaaaaaa')).to.be.true;
});

Die getPassword-Funktion folgt der Node.js-Konvention, die aussagt, dass eine Callback-Funktion immer zuerst mit einem Fehlerobjekt aufgerufen wird und erst das zweite Argument der eigentliche Wert ist. Im Erfolgsfall weist das Fehlerobjekt den Wert null auf. Der Spy muss also mit den Werten null und aaaaaaaa aufgerufen worden sein, wenn alles fehlerfrei funktioniert hat. In diesem Beispiel haben Sie einen anonymen Spy im Einsatz gesehen, Sie können bei Bedarf auch einen Spy um eine Funktion oder die Methode eines Objekts erstellen. Möchten Sie beispielsweise mehr über die Aufrufe der toString-Methode der PasswordGenerator-Instanz pw wissen, erzeugen Sie den erforderlichen Spy mit folgendem Ausdruck: sinon.spy(pw, ‘toString’).

Timing

Bei der Entwicklung Ihres APIs gilt ein Grundsatz, der über alle Systeme und Programmiersprachen hinweg gilt: Tests, die zu lange laufen, werden nicht oder zumindest viel zu selten ausgeführt. Deshalb lautet eine Anforderung an gute Unit-Tests, dass das Ergebnis schnell vorliegt. Sobald Sie mehrere Sekunden bis Minuten warten müssen, werden Sie Ihre Tests tendenziell nicht mehr so häufig ausführen, und es schleichen sich zunehmend Fehler ein. Einer der Hauptgründe für eine lange Laufzeit von Tests sind zeitabhängige Funktionen. Stellen Sie sich nur einmal vor, Sie haben ein Feature implementiert, das eine Zeitverzögerung von einer Sekunde beinhaltet, und Sie schreiben zehn Tests für dieses Feature. Die Laufzeit dieser Tests ist dann mindestens zehn Sekunden. Hinzu kommt noch der Overhead durch das Testframework und die Tests selbst. Und hier sprechen wir nur von einem einzigen Feature. Rechnen Sie nun den Zeitaufwand für Ihre komplette Applikation hoch, sind wir wahrscheinlich schon weit über einer Minute. Eine elegante Lösung für dieses und einige weitere Probleme bieten Fake Timer – ein Feature der Testframeworks, mit dem Sie die Kontrolle über die Zeit von Node.js übernehmen. Ähnlich wie bei den Spys sind Fake Timer ebenfalls Wrapper um die ursprüngliche Funktionalität, nur dass es hier keine normalen Funktionen, sondern das Date-Objekt und die setTimeout– und setInterval-Funktionen sind. Im folgenden Test überprüfen Sie, ob eine Klasse, die vom EventEmitter von Node.js ableitet, jede Sekunde ein Event auslöst. Listing 8 zeigt Ihnen die Implementierung dieser Klasse.

const SE = require('../src/event');
const sinon = require('sinon');
const expect = require('chai').expect;
describe('SeconEmitter', function() {
  let clock;
  before(function() {
    clock = sinon.useFakeTimers();
  });
  after(function() {
    clock.restore();
  });
  it('should emit every second', function() {
    const spy = sinon.spy();
    const se = new SE();
    se.on('data', spy);
    se.start();
    clock.tick(3000);
    se.stop();
    expect(spy.callCount).to.equal(3);
  });
});

Das Fake-Timer-Feature von SinonJS wird durch einen Aufruf von sinon.useFakeTimers aktiviert. Ab diesem Zeitpunkt ist die Zeit für Ihren Node.js-Prozess angehalten und Sie müssen mit einem Aufruf von clock.tick die Zeit weiterlaufen lassen, wobei clock der Rückgabewert der useFakeTimers-Methode ist. Nachdem die Tests durchlaufen wurden, sollten Sie die Fake Timer wieder zurücksetzen. Das geschieht mit der restore-Methode des clock-Objekts. Allgemein gilt, dass jeder Test die Umgebung wieder sauber hinterlassen sollte, sodass die nachfolgenden Tests keine Seiteneffekte befürchten müssen.

Neben der Zeitkontrolle können Sie mit den Fake Timers auch das Datum steuern. Auch hier gilt wieder, dass diese Manipulation nur für den aktuellen Node.js-Prozess und nicht für das gesamte System gilt. Um ein bestimmtes Datum zu setzen, übergeben Sie der useFakeTimers ein Date-Objekt mit dem entsprechenden Datum.

Async

Eine der großen Stärken von Node.js ist das Non-blocking-I/O-Prinzip. Zur Laufzeit Ihres API-Service lagert Node.js zahlreiche Operationen an das Betriebssystem oder andere Systeme aus und kümmert sich nur um die Abarbeitung Ihrer Applikationslogik. Die Anfrage bei einer Datenbank oder einem anderen Web Service blockiert also die Ausführung Ihrer Applikation nicht. Der Nachteil, der durch diese Asynchronität entsteht, ist, dass die Logik nicht mehr sequenziell abgearbeitet werden kann, sondern sehr viel mit Callback-Funktionen oder ähnlichen Strukturen gearbeitet wird. Testen Sie eine solche asynchrone Logik wie beispielsweise das Lesen aus einer Datei, ist Ihr Test schon beendet, bis das Ergebnis der Operation vorliegt. Häufig lassen sich solche asynchronen Problemstellungen umgehen, indem Stubs oder Fake Timer eingesetzt werden. Hin und wieder kommt es jedoch vor, dass Sie auch asynchronen Code testen müssen. Frameworks wie Mocha oder Jasmine lösen dieses Problem, indem Sie den Testfunktionen eine optionale Callback-Funktion übergeben. Definieren Sie in der Callback-Funktion Ihres Tests einen Parameter, erwartet das Framework, dass dieser am Ende des Tests als Funktion aufgerufen wird. Geschieht das nicht, wartet Mocha bis zum voreingestellten Timeout und lässt dann den Test fehlschlagen. Listing 9 enthält einen solchen asynchronen Testfall.

const asyncFunction = require('../src/async');
const sinon = require('sinon');
const chai = require('chai');
const expect = chai.expect;
describe('async', function() {
  it('should support async functions', function(done) {
    const spy = sinon.spy();
    asyncFunction(1, spy);
    setTimeout(() => {
      expect(spy.callCount).to.equal(1);
      done();
    }, 2);
  });
});

Die einfachste Variante, mit der Sie Asynchronität in Ihrer Applikation erzeugen können, ist die Verwendung von setTimeout. Die asyncFunction im Test macht nichts weiter als die übergebene Callback-Funktion nach dem angegebenen Delay, also einer Millisekunde, auszuführen. Der erste Schritt für den Umgang mit asynchronen Funktionen ist die Angabe des Parameters done. Danach folgen Sie, wie gewohnt, dem Triple-A-Muster und erzeugen ein Spy-Objekt, rufen die asyncFunction mit dem Delay und dem Spy auf. Der Assert-Schritt erfolgt um zwei Millisekunden verzögert und prüft, ob die Spy-Funktion einmal aufgerufen wurde.

Promises

Zugegebenermaßen ist der Umgang mit Callback-Funktionen nicht mehr ganz zeitgemäß. Vor allem, nachdem seit Version 8 von Node.js die Funktion util.promisify vorgestellt wurde. Diese Methode wandelt Funktionen, die mit Callback-Funktionen arbeiten, die der Node.js-Konvention mit dem Fehlerobjekt als erstes Argument folgen, in Promises um. Eine Promise ist ein Objekt, das für die Erfüllung einer asynchronen Operation steht. Promises kommen vor allem zur Auflösung ineinander geschachtelter Callbacks und zur asynchronen Flusssteuerung zum Einsatz. Baut man die asyncFunction so um, dass sie statt einer Callback-Funktion Promises unterstützt, sieht der zugehörige Test wie in Listing 10 aus.

const asyncFunction = require('../src/async');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
const expect = chai.expect;
chai.use(chaiAsPromised);
describe('async', function() {
  it('should work with promises', function() {
    expect(asyncFunction(1)).to.eventually.equal('Hello World');
  });
});

Der Test nutzt eine Erweiterung von Chai mit dem Namen chai-as-promised. Diese Erweiterung fügt die eventually-Eigenschaft zu Chai hinzu. Mit ihr können Sie solche Promise-basierten Routinen problemlos testen und müssen keine umständlichen Callback-Funktionen oder den done-Callback von Mocha nutzen.

Mockery

In Node.js-Applikationen wird das Modulsystem dazu verwendet, die Kopplung der einzelnen Module voneinander zu lösen. Mit dem Modulsystem beschreiben Sie einerseits, welche Funktionalität Ihr aktuelles Modul der Außenwelt zur Verfügung stellt, und andererseits, welche Abhängigkeiten Sie in Ihrem Modul benötigen. Dabei setzen Sie häufig nicht nur Node.js-eigene Module wie das Dateisystem-Modul oder das HTTP-Modul ein, sondern auch selbstgeschriebene Module oder Module von Drittanbietern, die Sie über npm installieren. Der API-Ansatz Ihrer Applikation setzt sich also auch in ihrem Inneren fort. Sie sollten darauf achten, dass Sie möglichst in sich geschlossene Module implementieren, die eine definierte Schnittstelle nach außen geben und über diese mit anderen Modulen kommunizieren. Schreiben Sie nun einen Test für ein solches Modul, werden sämtliche Abhängigkeiten geladen. Das bedeutet, dass Sie diese ebenfalls testen. Für einen Unit-Test ist das jedoch in den seltensten Fällen erstrebenswert. Eine Lösung für dieses Problem bietet das Paket Mockery. Es hebelt das Modulsystem von Node.js aus und gibt Ihnen die Möglichkeit, einzugreifen. Listing 11 zeigt Ihnen einen solchen Eingriff in das Dateisystem-Modul von Node.js.

const mockery = require('mockery');
const expect = require('chai').expect;
describe('FileReader', function() {
  before(function() {
    mockery.enable();
  });
  after(function() {
    mockery.disable();
  });
  it('should read a file', function(done) {
    var fsMock = {
      readFile: function(name, enc, cb) {
        cb(null, 'Top secret!');
      }
    };
    mockery.registerMock('fs', fsMock);
    const reader = require('../src/fs');
    reader('/etc/passwd', data => {
      expect(data).to.equal('Top secret!');
      done();
    });
  });
});

Nach der Installation von Mockery mit dem Kommando npm install mockery kann das Werkzeug eingebunden und mit einem Aufruf der enable-Methode aktiviert werden. Auch hier gilt wieder der Grundsatz: Was eingeschaltet wird, sollte auch wieder ausgeschaltet werden. Und so sollten Sie die disable-Methode nach Ihrem Test aufrufen, um Mockery wieder zu deaktivieren. Mit Mockery lassen sich dann für die verschiedensten Module Mocks registrieren, die die ursprüngliche Funktionalität ersetzen. Damit Mockery ordnungsgemäß funktionieren kann, muss es vor der Registrierung der zu testenden Funktionalität aktiviert und konfiguriert werden.

Umgang mit Abhängigkeiten

Die Verwendung von Mockery ist nicht die einzige Lösung im Umgang mit Abhängigkeiten. Es ist auch möglich, durch eine geschickte Gestaltung des Quellcodes derartige Eingriffe zumindest zu reduzieren, wenn nicht sogar ganz zu vermeiden. Das Geheimnis liegt hierbei in der Übergabe der Abhängigkeiten an die Module zur Laufzeit. Gehen Sie beispielsweise von einem Model in Ihrem API-Service aus, das einen Teil der Businesslogik kapselt und Zugriff auf Ihre MySQL-Datenbank benötigt, um die Daten auszulesen beziehungsweise sie wieder in die Datenbank zu schreiben. Eine solche Datenbankverbindung können Sie natürlich auch in einem solchen Model aufbauen, oder Sie erzeugen die Verbindung zur Datenbank schon zuvor an einer zentralen Stelle und übergeben das Verbindungsobjekt an das Model. Auf diese Weise können Sie der zu testenden Struktur je nach Umgebung entweder die Datenbankverbindung oder einen Stub übergeben.

Integrationstests

Bisher haben wir uns nur mit Unit-Tests für Node.js beschäftigt. Eine andere Art der Tests, die ebenfalls von großer Wichtigkeit für Ihren API-Service sind, sind End-to-End-Tests. Bei diesen wird die komplette Applikation überprüft. Bei einem API-Service werden die Schnittstellen angesprochen, die Anfragen bis zur Datenbank weitergeleitet und entsprechende Antworten generiert. Sie testen in diesem Fall Ihr API so, wie es auch von den Benutzern, also meist anderen Systemen, verwendet wird. Generell ist die Laufzeit von End-To-End-Tests wesentlich länger als die normaler Unit-Tests, da bei dieser Art von Tests das komplette Bootstrapping der Applikation, Berechnungen, Netzwerkkommunikation und die Wartezeit durch Drittsysteme hinzukommt. Da Sie sich im Fall von End-To-End-Tests nicht mehr nur im Rahmen Ihrer Node.js-Applikation aufhalten, sind Sie hier auch nicht mehr an Node.js-Werkzeuge gebunden. Dennoch gibt es einige Hilfsmittel und Frameworks, die Ihnen die Arbeit erheblich erleichtern. Das sind unter anderem Supertest, ein Werkzeug, das als npm-Paket vorliegt und das HTTP-Anfragen an Endpunkte wie beispielsweise einen Web Service für Sie abstrahiert. In Listing 12 sehen Sie ein einfaches Beispiel eines Tests, der für Supertest geschrieben ist.

const request = require('supertest');
request(app)
  .get('/questions')
  .expect('Content-Type', /json/)
  .expect('Content-Length', '16')
  .expect(200)
  .end(function(err, res) {
    if (err) throw err;
  });

Supertest ist in seiner Anwendung sehr flexibel, es abstrahiert zwar den Zugriff auf den Request und die Response, erlaubt aber auch Zugriff auf die zugrunde liegenden Schnittstellen, die eine sehr genaue Kontrolle der Tests erlauben.

Fazit

Der Aufbau eines API gehört zu den häufigsten Einsatzzwecken von Node.js. Aus diesem Grund existiert auch eine Vielzahl von Werkzeugen für diesen Zweck. Von Testframeworks wie Jasmine oder Mocha über Bibliotheken wie Chai oder Sinon.js bis hin zu Hilfsmitteln wie Supertest, mit denen sich Ihr API in seiner Gesamtheit testen lässt. Beim Testen Ihres API-Service ist vor allem wichtig, dass Sie eine Reihe von Konventionen aufstellen, sodass Ihre Tests stets einen ähnlichen Aufbau aufweisen. Verwenden Sie immer ähnliche Muster, fällt neuen Kollegen die Einarbeitung leichter, und auch die Fehlersuche in bestehenden Tests wird weniger aufwendig. Eine gute Wartbarkeit von Tests ist eines der wichtigsten Ziele bei der Erzeugung von Tests, da diese stets mit dem Quellcode Ihrer Applikation weiterentwickelt werden. Aus diesem Grund sollten Sie beim Testen auch möglichst einen Bottom-up-Ansatz verfolgen, bei dem Sie zunächst die kleinsten Codeeinheiten, die Units, also meist Funktionen, testen. Danach wird der Scope der Tests Schritt für Schritt immer größer, bis Sie schließlich das komplette API mit all seinen Abhängigkeiten in einem End-to-End-Test überprüfen.

Entwickler Magazin

Entwickler Magazin abonnierenDieser Artikel ist im Entwickler Magazin erschienen.

Natürlich können Sie das Entwickler Magazin über den entwickler.kiosk auch digital im Browser oder auf Ihren Android- und iOS-Devices lesen. In unserem Shop ist das Entwickler Magazin ferner im Abonnement oder als Einzelheft erhältlich.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -