BDD für Alexa Skills – Teil 3: Unit-Testing mit Jest

Keine Kommentare

Dies ist der dritte Teil einer Serie von Blog-Artikeln über die Entwicklung eines Alexa Skills. Diesmal machen wir auf dem Weg zu Behaviour-Driven-Development (BDD) einen Zwischenstopp bei Test-Driven-Development (TDD). Die Grundlagen dafür legen wir mit der Konfiguration von Jest um Unit-Tests für unseren Skill verfassen zu können.

Was bisher geschah

Mit dem Ende des zweiten Artikels sind wir in der Lage, die Lambda-Funktion die unseren Skill bedient mit einer lokalen IDE zu entwickeln und dann in der Cloud zu aktualisieren.

Nächste Schritte

Um unsere Skill-Entwicklung weiter zu beschleunigen, wollen wir Fehler möglichst früh finden, also nicht erst wenn die Lambda-Funktion in der Cloud deployed wurde. Um dies zu erreichen werden wir im Rahmen dieses Blog-Posts die lokale Entwicklung um folgende Dinge erweitern:

  • Statische Analyse des Codes mit ESLint
  • Typechecking mit Flow
  • Unit-Tests mit Jests

Zusätzlich erfolgt die Übersetzung (Transpilierung) des JavaScript-Codes mit babel (damit wir in unserem Skill moderne JavaScript Features verwenden können) und ein Bundling der resultierenden Dateien mit webpack.

Die Vorteile einer statischen Analyse oder eines Typecheckers aufzuzeigen würde den Rahmen dieser Artikelserie sprengen. Für unser Ziel, Fehler in der Skill-Entwicklung möglichst früh zu finden, sind sie sehr gut geeignet. Bei der Wahl eines Typsystems für JavaScript wäre Typescript eine naheliegende Alternative gewesen. Ich habe mich in diesem Projekt für Flow entschieden, da ich mit Flow bereits vertraut war und ich mich somit besser auf das Lernen der Skill-Entwicklung konzentrieren konnte (ohne noch eine weitere neue Technologie lernen zu müssen).

Für die Unit-Tests habe ich mich für Jest entschieden. Eine Einleitung zu diesem JavaScript Testing Framework findet sich hier im codecentric Blog.

Setup von ESLint, Flow, Jest, Babel und Webpack

Das Setup dieser Toolchain war leider kein linearer Prozess. Unterschiedliche Versionen von babel und jest gepaart mit vielen verschiedenen Konfigurationsmöglichkeiten führten zu einigen Schleifen in der Entwicklung. Aus diesem Grund teile ich hier nur die finalen Konfigurationsdateien.

Hervorheben möchte ich die finale webpack-Konfiguration, hier haben zwei kleine Fehlkonfigurationen mehrtägige Debugging-Sessions verursacht (die leider nicht einfach zu diagnostizieren waren, da der Skill grundsätzlich funktionierte, nur Zugriffe auf einen Persistent Adapter nicht möglich waren).

Wichtig waren hier die beiden Einstellungen:

target: 'node',
externals: [ 'aws-sdk' ]

Die vollständigen Konfigurationsdateien findet ihr hier im Repository auf gitlab.

Projektstrukturierung

Nach dem Setup der diversen Tools sind wir nun in der Lage, erste Unit-Tests mit Jest zu verfassen.

Schauen wir uns zunächst einmal den Aufbau der Lambda für unseren Skill an, um uns eine sinnvolle Strukturierung zu überlegen. Neben vielen Konfigurationsdateien besteht die Lambda-Funktion aktuell nur aus einer einzigen JavaScript-Datei, der index.js.

In der Datei finden wir Handler für verschiedene Requests und Code der über einen Skillbuilder die eigentliche Lambda-Funktion zusammenbaut und für den Aufruf durch unseren Skill exportiert.

Für jeden Intent den wir in unserem Voice Interaction Model definieren, müssen wir einen IntentHandler in unserer Lambda implementieren. Um diese Handler isoliert testen zu können, ist es sinnvoll diese in eigene Dateien zu extrahieren. In der index.js verbleibt dann nur noch der Glue-Code, der die einzelnen Handler zur exportierten Lambda-Funktion zusammenbaut.

Mit der Einführung von Babel und webpack sind alle Quelldateien in das Unterverzeichnis src gewandert. Die von webpack generierte Datei wird hingegen im Unterverzeichnis build abgelegt.

Es ergibt sich bislang folgende Verzeichnisstruktur:

  • /build: Hier landet der build-output. Momentan ist dies die von webpack erzeugte index.js
  • /flow-typed: In diesem Verzeichnis befinden sich Typdefinitionen von flow (konkret bislang die Typdefinitionen für jest)
  • /src:
    • /handler: Im Unterverzeichnis handler landen alle Handler für Intents unseres Skill, plus weitere Handler die nicht an einen bestimmten Intent gebunden sind. Da die Anzahl an Intents im Weiteren wachsen wird, sind diese Handler in weitere Unterverzeichnisse aufgeteilt.
      • /lifecycle: Handler für bestimmte Ereignisse im Lebenszyklus eines Skills (z. B. launch, stop/ cancel, sessionEnded)
      • /help: Handler für den Help Intent
    • /own-flow-types: Selbst definierte flow-typen für externe Bibliotheken die ich bei Bedarf nach und nach ergänzt habe. Typescript hat hier die breitere Unterstützung, aber die Flow-Typen zu definieren geht leicht von der Hand und hilft beim Verständnis der eingesetzten Bibliotheken.
        • /aws-sdk: Flow Typen für das aws-sdk
  • voiceUI: Dieses Verzeichnis nutzen wir um lokal eine Kopie des Voice Interaction Model zu halten (Details dazu im vierten Teil dieser Blogserie).

Aktualisierung der Lambda

Durch die geänderte Verzeichnisstruktur und die Einführung von babel und webpack muss auch die Aktualisierung der Lambda-Funktion in der Cloud angepasst werden. Die erste notwenige Änderung ist der Befehl yarn uploadin der package.json. Dieser muss nun zunächst den Build mit webpack triggern und dann die erzeugte Datei aus dem Build-Verzeichnis zippen um dieses Zip dann mit der aws-cli in der Cloud zu aktualisieren.

Der angepasste Befehl sieht nun so aus:

    "scripts": {
        ...
        "webpack": "webpack",
        "upload": "yarn webpack && zip -r fuenferpasch.zip package.json build/main.js && aws lambda update-function-code --function-name arn:aws:lambda:eu-west-1:668867983508:function:serverlessrepo-fuenferpas-alexaskillskitnodejsfact-40MX9KL1B0PB --zip-file fileb://./fuenferpasch.zip"
    }

 

Durch die geänderte Verzeichnisstruktur liegt aber nun auch die generierter Lambda-Funktion an einer anderen Stelle. Dies müssen wir auch in der Lambda-Konfiguration in der Cloud anpassen, sonst wird bei der Ausführung immer noch am ursprünglichen Ort gesucht. Dazu öffnen wir die Lambda Management Console, wählen aus der Liste der Lambda-Funktionen die Funktion zu unserem Skill aus und passen im Folgenden den Handler an:
Screenshot: Anpassung des Orts an dem AWS den exportieren Handler der Lambda Funktion erwartet.

Der erste Unit-Test

Schauen wir uns einmal den Aufbau eines Handlers am Beispiel des LaunchHandlers an:

// @flow
import {SKILL_NAME} from '../../consts';
import type {HandlerInput, Response} from '../../own-flow-types/ask-sdk/inputHandler';
 
export const LaunchRequestHandler = {
    canHandle(handlerInput: HandlerInput): boolean {
        const request = handlerInput.requestEnvelope.request;
        return request.type === 'LaunchRequest';
    },
    handle(handlerInput: HandlerInput): Response {
        const speech = 'Willkommen bei Fünferpasch';
        return handlerInput.responseBuilder
            .speak(speech)
            .withSimpleCard(SKILL_NAME, speech)
            .getResponse();
    }
};

Ein Handler ist also ein Objekt das zwei Methoden anbieten:

  • canHandle: wird vom Alexa Voice Service aufgerufen um zu bestimmen, ob sich ein Handler für einen bestimmten Request verantwortlich fühlt. Für den LaunchRequestHandler geschieht dies rein anhand des Request-Typs. Für einen Intent-Handler muss auch noch der Intent-Name hinzugezogen werden um zu entscheiden, ob der Handler genau für diesen Intent zuständig ist.
  • handle: wird aufgerufen, wenn entschieden wurde, dass genau dieser Handler für den Request verantwortlich ist. Dieser bekommt dann die vorverarbeiteten Benutzereingaben verpackt im handlerInput-Objekt, um diese zu verarbeiten und das Ergebnis als Response zur Verfügung zu stellen.

Beide Methoden lassen sich sehr gut mit einem Unit-Test testen. Auch der einfache Test der canHandle-Methode empfiehlt sich, denn es ist schnell passiert, dass man einen neuen Handler anlegt, aber den Intent-Namen nicht genau trifft (oder einen existierenden Handler kopiert und vergisst, den Namen anzupassen). Dann merkt man den Fehler erst nachdem der Skill gebaut, gezippt, hochgeladen und im Simulator gestartet wurde. Mit dem Test haben wir direkte Sicherheit.

Für den Test benötigen wir ein handlerInput-Objekt mit dem wir unseren Handler aufrufen können. Hierfür habe ich eine kleine Hilfsfunktion geschrieben, die uns ein konfigurierbares Mockobjekt zur Verfügung stellt.

Mit dieser Hilfsfunktion sieht ein Test für den LaunchRequestHandler dann so aus:

import {LaunchRequestHandler} from './launchRequest';
import {getMockHandlerInputForIntent, getMockHandlerInputForLaunch} from '../../test/mockHandlerInput';
import type {MockResponse} from '../../test/mockHandlerInput';
 
describe('LaunchRequestHandler', () => {
    describe('can handle should', () => {
        it('return true for a LaunchRequest', () => {
            expect(LaunchRequestHandler.canHandle(getMockHandlerInputForLaunch())).toEqual(true);
        });
 
        it('return false for another intent', () => {
            expect(LaunchRequestHandler.canHandle(getMockHandlerInputForIntent('unhandledIntent'))).toEqual(false);
        });
    });
 
    describe('handle should', () => {
        it('not crash', async () => {
            expect(LaunchRequestHandler.handle(getMockHandlerInputForLaunch())).toBeDefined();
        });
 
        it('say hello', async () => {
            const response: MockResponse = LaunchRequestHandler.handle(getMockHandlerInputForLaunch());
            expect(response.speechText).toEqual('Willkommen bei Fünferpasch webpack');
        });
    })
});

Die canHandle-Methode wird mit zwei unterschiedlichen handlerInput-Objekten getestet. Einmal simulieren wir einen HandlerInput mit einem LaunchRequest und erwarten, dass die canHandle-Methode true zurück gibt, einmal simuliert der HandlerInput einen Request für einen Intent mit dem Namen ‚unhandledIntent‘, der erwartungsgemäß vom LaunchRequestHandler ignoriert wird.

Die handle-Methode erfährt ebenfalls zwei Prüfungen. Der erste Test prüft nur, dass die Methode ohne Fehler aufgerufen werden kann. Der zweite Test prüft hingegen, ob der Handler auch die erwartete Antwort für den Anwender generiert.

Das funktioniert, weil wir dem Handler unser eigenes Mock-handlerInput-Objekt mitgegeben haben. Die Response des Handlers wird generiert mit einem ResponseBuilder-Objekt, das dem Handler über den handlerInput zur Verfügung gestellt wird. Unser Mock-Objekt stellt dem Handler aber einen besonderen MockResponseBuilder zur Verfügung, der die Aufrufe des ResponseBuilders aufzeichnet und für spätere Überprüfungen im Test als MockResponse zur Verfügung stellt. So können wir im Test prüfen, ob im Handler die Methode speak mit einem bestimmten Text aufgerufen wurde, der uns im MockResponse-Objekt als speechText zur Verfügung steht. Dies können wir nach und nach für alle Methoden des ResponseBuilders ergänzen die wir in unseren Handler nutzen und testen wollen.

Momentan sieht der MockResponseBuilder so aus:

const getMockResponseBuilder = (): $Shape<MockResponse> => {
    const that: MockResponse = {
        speak: (speechText: string) => {
            that.speechText = speechText;
            return that;
        },
        reprompt: (repromptText: string) => {
            that.repromptText = repromptText;
            return that;
        },
        withSimpleCard: (title: string, cardText: string) => {
            that.simpleCardTitle = title;
            that.simpleCardText = cardText;
            return that;
        },
        withShouldEndSession: (shouldEndSession: boolean) => {
            that.shouldEndSession = shouldEndSession;
            return that;
        },
        withAskForPermissionsConsentCard: (permissions: string[]) => {
            that.requestedPermissions = permissions;
            return that;
        },
        addDelegateDirective: (updatedIntent): MockResponse => {
            const mockResponse: mockResponse = {
                ...that,
                updatedIntent
            };
            return mockResponse;
        },
        getResponse: () => that,
        speechText: '',
        repromptText: '',
        simpleCardText: '',
        simpleCardTitle: '',
        shouldEndSession: undefined,
        updatedIntent: undefined,
        hasDelegateDirective: false
    };
    return that;
};

Wir können nicht nur testen, ob unser Handler mit einem Text geantwortet hat, über eine ConsentCard vom Benutzer weitere Berechtigungen erfragt oder die Session beendet hat.

Ausblick

Mit diesem Setup haben wir einen Stand erreicht, mit dem wir die Handler für unseren Skill test-driven entwickeln können. Fehler beim Matching unseres Voice Interaction Models auf unsere Handler finden wir so aber noch nicht.
Darum geht es unter anderem im nächsten Teil dieser Artikelserie, indem wir lernen wie wir Akzeptanztests mit Hilfe von cucumber.js formulieren können um so unser gesamtes Setup vom Voice Interaction Model bis hinunter zum Handler testen zu können.

Stefan Spittank

Stefan ist seit 2016 für die codecentric AG am Standort Solingen tätig.
Anwendungen nutzbar zu machen und eine gute „User Experience“ zu erreichen ist sein täglich Brot. Dabei helfen ihm auch seine langjährigen Erfahrungen als Verantwortlicher für User Interfaces in der IT-Branche.

Kommentare sind geschlossen.

Kommentieren