Cucumber.js Alexa Test Flow

BDD für Alexa Skills – Teil 4 Akzeptanztests mit cucumber.js

Keine Kommentare

Dies ist der vierte Teil einer Serie von Blogeinträgen über die Entwicklung eines Alexa Skills. Diesmal beschäftigen wir uns mit der Entwicklung von automatisierten Akzeptanztests für unseren Skill unter Einsatz von cucumber.js, also Behaviour Driven Development (BDD) für einen Alexa Skill.

Was bisher geschah

In den ersten drei Beiträgen dieser Artikelserie haben wir einen Alexa Skill aufgesetzt. Schritt für Schritt wurde die Lambda-Entwicklung für diesen Skill um Linting, ein Typsystem und schließlich Unit-Tests mit Jest erweitert.

Aufbau eines Tests in cucumber.js

cucumber.js ist die JavaScript-Portierung von cucumber, einem Framework zur Ausführung von automatisierten (Akzeptanz-) Tests (wer sich für die Historie von cucumber interessiert kann hier mehr in einem Artikel von 2013 auf dem codecentric Blog lernen).

Cucumber erlaubt es uns, Testszenarien als Text zu definieren, die einer gewissen Struktur gehorchen müssen.
Ein Testszenario wird eingeleitet mit dem Schlüsselwort Szenario: gefolgt von einem Namen für den Test.

Der Testablauf wird dann mit Schlüsselwörten versehen:

  • Angenommen: Definiert einen Ausgangszustand für den Test, z.B. dass der Skill bereits gestartet ist. Wenn unser Skill von der Tageszeit abhängig ist, könnten wir hier z. B. schreiben „Angenommen es ist sieben Uhr morgens“.
  • Wenn: Gibt einen Schritt an, der in dem Test ausgeführt werden soll.
  • Dann: Beschreibt unsere Erwartung, was durch den Test passieren soll.

Ziel ist es, den Test für unseren Alexa Skill so zu schreiben, als würden wir einen Dialog mit Alexa führen. Dies könnte in unserem Würfelspiel vielleicht so aussehen:

 
Szenario: Würfel mit n Würfeln. Alexa erfragt die Anzahl, wenn der Anwender diese initial nicht nennt
Angenommen der Zufallsgenerator generiert eine Sequenz 2,5,3,2,3
Und der Anwender hat den Skill gestartet
Wenn der Anwender sagt: Bitte würfeln
Dann fragt Alexa: Mit wie vielen Würfeln möchtest du würfeln?
Wenn der Anwender die Frage mit 5 beantwortet
Dann sagt Alexa: Okay. Du hast zweimal eine 2, zweimal eine 3 und eine 5 gewürfelt

 

Jeden der Angenommen-, Wenn- und Dann-Schritte müssen wir erst implementieren, damit cucumber weiß was dafür zu tun ist. Wir könnten nun einen speziellen Schritt Wenn der Anwender sagt: Bitte würfeln implementieren und dann einen neuen „Würfel Intent“ aufrufen. Aber schöner wäre es, wenn wir nur wenige generischen Schritte implementieren würden, wie z.B.:

Wenn der Anwender sagt: utterance
Dann sagt Alexa: Antwort

Der (Mock) Voice Service

Um generische Steps für den Aufruf unseres Skill aus einem cucumber-Test zu entwerfen, schauen wir noch einmal auf das Diagramm aus dem ersten Blogpost dieser Serie. Dieses Diagramm hilft uns, zu verstehen wie unser Skill von Alexa ausgeführt wird:
Schematischer Ablauf des Aufrufs eines Alexa Skills im produktiven Fall

Der Anwender sagt einen Befehl zu seinem Echo-Gerät. Dieser Sprachbefehl wird in die Cloud übertragen und vom Alexa Voice Service verarbeitet.

Der Voice Service nutzt unser Voice Interaction Model um zu prüfen, ob die Spracheingabe des Nutzers zu einem unserer da definierten Intents passt. Wenn ja, baut der Voice-Service einen intentRequest mit dem aufzurufenden Intent und ggf. vorhandenen Slot-Werten zusammen und ruft damit unseren Skill auf. Der Skill prüft welcher Handler für den Intent in der canHandle Methode true zurückgibt und ruft dann den passenden Handler auf.

Für unseren Akzeptanztest müssen wir also die Rolle des Voice Service selbst übernehmen und diesen durch einen speziellen Mock Voice Service ersetzen:

Schematischer Ablauf des Aufrufs eines Alexa Skill bei der Ausführung eines Akzeptanztests mit cucumber.js

Um dies tun zu können brauchen wir lokal Zugriff auf unser definiertes Voice Interaction Model. Dieses finden wir in der Alexa Developer Console. Von dort navigieren wir zu unserem Skill und können dann über die Navigation links den JSON Editor aufrufen:

Screenshot: Der JSON Editor unseres Voice Interaction Model

Wir kopieren uns den Inhalt und legen mit diesem eine neue Datei namens „interactionModel.json“ im Verzeichnis /VoiceUI an (Anmerkung: neben der aws-cli die wir zur Aktualisierung der Lambda nutzen, existiert ein eigenes Commandline Interface für Alexa Skills, das hier vielleicht hilfreich wäre. Dieses habe ich aber noch nicht ausprobiert).

Momentan haben wir in unserem Voice Interaction Model nur einen einzigen neuen Intent angelegt. Dieser Intent (starteSpiel) kann nur durch einen einzigen Satz aufgerufen werden, mit einer Variablen (einem Slot) für die Anzahl an Spielern.

Für die bessere Veranschaulichung erweitern wir unseren Intent nun um einen weiteren Slot. Wir ersetzen das Wort Mitspielern durch einen Slot {Spielern} mit möglichen Werten einem eigenen Slot-Typ {SpielernSlot} mit den möglichen Werten: Mitspielern und Spielern (um dem Anwender etwas mehr Freiheiten in der Formulierung zu gestatten).

Unser verändertes Voice Interaction Model sieht somit so aus:

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "fünferpasch",
            "intents": [
                {
                    "name": "AMAZON.FallbackIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "starteSpiel",
                    "slots": [
                        {
                            "name": "spieleranzahl",
                            "type": "AMAZON.NUMBER"
                        },
                        {
                            "name": "spielern",
                            "type": "SpielernSlot"
                        }
                    ],
                    "samples": [
                        "Starte ein Spiel mit {spieleranzahl} {spielern}"
                    ]
                }
            ],
            "types": [
                {
                    "name": "SpielernSlot",
                    "values": [
                        {
                            "name": {
                                "value": "Mitspielern"
                            }
                        },
                        {
                            "name": {
                                "value": "Spielern"
                            }
                        }
                    ]
                }
            ]
        }
    }
}

Es gibt also einige Standard-Intents, plus den starteSpiel Intent, mit den zwei Slots:

  • spieleranzahl vom Typ AMAZON.number
  • spielern vom Typ SpielernSlot

Der SlotTyp SpielernSlot ist ein benutzerdefinierter Slot-Typ, der durch die möglichen Werte Spielern und Mitspielern ersetzt werden kann. Wir nutzen den Slot-Typ hier also nur, um die Anzahl an verschiedenen Utterances zu reduzieren und gleichzeitig dem Anwender verschiedene Formulierungen zu erlauben. Der Hauptunterschied ist hier, dass der Slot spieleranzahl später von uns weiter verwendet wird, während es uns egal ist, ob der Anwender unseren Skill mit „Spielern“ oder „Mitspielern“ adressiert hat.

Ein generischer Wenn der Anwender sagt Step für cucumber.js

Mit unserem Mock Voice Service wollen wir einen generischen Step für die Verarbeitung einer Spracheingabe eines Nutzers anbieten. Wir müssen also zu der Spracheingabe den passenden Intent ermitteln und unsere Lambda-Funktion mit einem entsprechenden Intent-Request aufrufen.

Um diesen Intent zu ermitteln, liest unser Mock Voice Service das lokal gespeicherte Voice Interaction Model und speichert sich zu jedem möglichen Intent jede mögliche Utterance. Um diese Utterances im Test auf die Spracheingabe des Nutzers zu matchen, übersetzen wir jede Utterance in eine Regular Expression. In der erzeugten Regular Expression ersetzen wir die Slot-Werte durch die für den passenden Slot-Type gültigen Eingaben. Das klingt komplizierter als es wirklich ist. Machen wir es konkreter an unserem obigen Beispiel:

Die Utterance:
"Starte ein Spiel mit {spieleranzahl} {spielern}"

wird eingelesen. Anhand der geschweiften Klammer erkennen wir zwei Slots. Über die Slot-Typen ersetzen wir diese Slots nun durch eine Regular Expression zu:

"^Starte ein Spiel mit (.*) (Spielern|Mitspielern)$"

Mit dem „^“ und „$“ Zeichen legen wir fest, dass unsere Regular Expression nur matchen soll, wenn die Spracheingabe genau mit dem ersten Wort anfängt und dem letzten Wort aufhört, dadurch schließen wir für unseren Test Teilmatches aus.

Die Slot-Werte aus der Utterance haben wir in der Regular Expression durch Gruppen (gekennzeichnet durch die runden Klammern) ersetzt:

  • Vordefinierte Slot-Types (erkennbar daran, dass der Typ mit „AMAZON.“ anfängt, ersetzen wir momentan durch (.*) was auf beliebige Eingaben matched (Für AMAZON.number könnten wir später noch spezifischer werden).
  • Benutzerdefinierte Slot-Types ersetzen wir durch eine Oder-verkettete Liste aller für den Slot-Typ gültigen Eingaben. Im Beispiel sind das „Spielern“ und „Mitspielern“.

Im Test müssen wir dann nur noch die Spracheingabe aus dem Test gegen alle erzeugten Regular Expressions testen. Haben wir einen Match, kennen wir auch den aufzurufenden Intent und über die Gruppen in der Regular Expression ermitteln wir die Werte zu den ermittelten Slots.

Mit diesen Informationen können wir uns dann einen IntentRequest zusammenbauen. Hierfür nutzen wir einige Template-Requests, die wir im Projekt als JSON-Dateien ablegen und mit dem aufzurufenden Intent und den Slot-Informationen aktualisieren.

Die Implementierung unseres „Wenn der Anwender sagt“ cucumber Steps ist:

When(/^der Anwender sagt[:]? (.*)$/, async function (utterance) {
    const allUtterances = getAllUtterances();
 
    const matchingIntent: ?IntentInvocation = findMatchingIntent(allUtterances, utterance);
 
    expect(allUtterances).toHaveMatchingIntentFor(utterance);
    if (!matchingIntent) return;
 
    const slots = matchingIntent && matchingIntent.slots.reduce((acc, cur: Slot) => ({
        ...acc,
        [cur.name]: {
            name: [cur.name],
            value: cur.value,
            confirmationStatus: 'NONE',
            source: 'USER'
        }
    }), {});
 
    const json = fs.readFileSync('features/support/mockVoiceService/requestJsonTemplates/intentRequest.json', 'utf-8');
    const intentRequest = JSON.parse(json);
 
    intentRequest.request.intent.name = matchingIntent.intentName;
    intentRequest.request.intent.slots = slots;
 
    this.lastRequest = intentRequest;
 
    await executeRequest(this, this.skill, intentRequest);
});

Zuerst holen wir uns mit getAllUtterances wie oben beschrieben Regular Expressions (und die dazugehörigen Intents) für alle Utterances in unserem Voice Interaction Model.

findMatchingIntent ermittelt dann den aufzurufenden Intent und die zu setzenden Werte für alle eventuell vorhandenen Slots.

expect(allUtterances).toHaveMatchingIntentFor(utterance); prüft, dass wir auch wirklich einen Intent gefunden haben (sonst wäre der Test schon hier fehlgeschlagen). Der matcher toHaveMatchingIntentFor ist ein Custom-Matcher, implementiert in der Datei expectations.js. Dadurch werden besser lesbare Fehlermeldungen in den Tests ermöglicht.

Die folgende Reduce-Anweisung dient dazu, alle ermittelten Slot-Werte aus einem Array in Form eines Dictionary-Objekts für den Skill zusammenzubauen.

Abschließend wird der Request zusammengebaut und mit executeRequest ausgeführt.

Überprüfung der Antwort des Skills

Mit dem nun definierten Step können wir aus einem cucumber-Test heraus einen Intent in unserem Skill aufrufen. Um zu überprüfen, ob der Aufruf erfolgreich war, müssen wir aber auch die Antwort überprüfen. Schauen wir hierzu zunächst auf die Methode executeRequest (die in Gitlab abgelegte Version ist geringfügig komplexer, da sie sich auch um die Weitergabe des Session-States zwischen verschiedenen Aufrufen kümmert):

async function executeRequest(world, skill, request) {
    return new Promise((resolve) => {
        // Add session Attributes to requests
        skill(request, {}, (error, result) => {
            world.lastError = error;
            world.lastResult = result;
            // Store session attributes from response for future requests
            resolve();
        });
    });
}

Die executeRequest Methode ist als Promise definiert. Der Promise wird resolved, sobald wir eine Antwort von unserem Skill bekommen haben. Unser Skill wird mit dem zusammengebauten Request aufgerufen und bekommt als dritten Parameter eine Callback-Funktion. Diese Callback-Funktion wird aufgerufen, wenn unser Skill den Request verarbeitet hat. Je nach Erfolg ist dann entweder der Parameter error oder der Parameter result gesetzt. Beide Parameter speichern wir uns im globalen World-Objekt von Cucumber (dort ist der aktuelle Zustand des Tests gespeichert) und können den Promise resolven.

Mit dieser Information wird dann die Implementierung eines Steps zur Überprüfung der Antwort eines Skills ganz einfach:

Then(/^antwortet Alexa mit[:]? (.*)$/, function (expectedResponse) {
    expect(this.lastResult.response.outputSpeech.ssml).toEqual(`${expectedResponse}`);
});

Jeder implementierte Step in cucumber.js erhält über this Zugriff auf das globale world-Objekt (Achtung: deswegen dürfen cucumber-Steps nie mit arrow-functions implementiert werden, da mit arrow-functions das Verhalten des this-Keywords geändert wird). Also haben wir über this.lastResult direkt Zugriff auf das letzte Ergebnis unseres Lambda-Aufrufs. Wenn wir von unserem Skill (wie in diesem Fall) eine Sprachausgabe erwarten, muss diese im outputSpeech-Attribut der Response stehen.

Den aktuellen Stand des Skills findet ihr hier auf Gitlab.

Ausblick

Wir haben nun ein erstes Setup um Akzeptanztests für unseren Alexa Skill in cucumber.js zu formulieren. Mit dieser Technik lassen sich auch weitere Annahmen an die Skill-Antwort formulieren, z.B. dass der Skill Berechtigungen vom Anwender erfragt oder einen Text auf einem eventuell vorhandenen Bildschirm ausgibt.

Auch die Behandlung von Attributen für die Zwischenspeicherung von Werten haben wir noch nicht berücksichtigt. Alexa Skills können bestimmte Werte für die Lebensdauer eines Requests, einer Session oder persistent (zwischen-)speichern. Damit sollte auch unser Testing-Framework umgehen können. Dies wird Bestandteil des nächsten Artikels dieser Blogserie sein.

 

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.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.