Dies ist der fünfte Teil einer Serie von Blogposts über Behaviour Driven Development (BDD) eines Alexa Skills. In diesem Beitrag erweitern wir unser Testframework um die Behandlung von Status-Informationen.
Was bisher geschah
In den ersten vier 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, Unit-Tests mit Jest erweitert und Akzeptanztests mit cucumber.js ergänzt.
State-Handling in unseren Tests
Auf unserem Weg zu Behaviour Driven Development für unseren Skill bildet der entwickelte Mock Voice Service die Grundlage um die Lambda-Funktion gezielt aus dem Test heraus aufzurufen und Erwartungen an die Ergebnisse zu formulieren.
In diesem Teil der Serie werden wir das Handling der Lambda im Test so verbessern, dass unser Skill auch State-Handling nutzen kann. Amazon erlaubt es uns, den Zustand eines Skills auf drei verschiedenen Ebenen zu speichern:
- auf Ebene des Requests,
- auf Ebene der Session oder
- persistent mit Hilfe eines PersistenceAdapters (Amazon bietet einen solchen Adapter z.B. für eine MongoDB an, der mit sehr wenig Aufwand konfiguriert werden kann)
Eine Speicherung rein auf Ebene eines einzelnen Requests ist sehr flüchtig und hat somit nur ein sehr begrenztes Einsatzgebiet. Spannender ist da schon die Speicherung von Daten für die Lebensdauer einer Session.
Was ist eine Alexa-Session
Bevor wir uns anschauen, wie wir Informationen für eine Session speichern können, sollten wir zunächst einmal klären, was genau eine Session denn im Kontext eines Alexa Skills ist:
- Eine Session beginnt in dem Moment, in dem der Nutzer den Skill startet.
- Eine Session endet, wenn:
- der Benutzer die Session explizit beendet (z.B. mit „Alexa, stopp“). Dann erhält unser Skill einen Stopp-Intent.
- der Benutzer nach der letzten Antwort von Alexa nicht innerhalb von acht Sekunden wieder mit dem Skill interagiert.
- Diese Zeit können wir einmal verlängern, wenn wir zusätzlich zur Sprachausgabe noch einen Reprompt in unsere Response einbauen. Mit diesem fragt Alexa dann noch einmal nach, kommt innerhalb von (erneuten) acht Sekunden dann wieder keine Antwort, wird der Skill beendet.
- Ein Skill wird auch beendet, wenn bei der Verarbeitung der Skill-Anfrage ein Fehler auftritt.
Die Lebensdauer einer Alexa-Session ist also eng daran gebunden, dass der Nutzer kontinuierlich mit dem Skill interagiert (und somit potenziell recht kurz). Das gilt im Übrigen für Custom-Skills, Musik- oder Video-Skills verhalten sich da anders.
Session-Attribute
Für die Dauer einer Session können wir uns den aktuellen Zustand in Session-Attributen merken. Diese Session-Attribute bekommen wir als Key-Value-Store über den AttributeManager
des handlerInput
bereitgestellt.
Für Session-Attribute bietet uns der Attribute-Manager zwei Methoden an:
getSessionAttributes
zum Auslesen der Session-AttributesetSessionAttributes
zum Setzen der Session-Attribute
Im starteSpiel
Intent unseres Würfelspiel-Skills wollen wir uns die Anzahl an Mitspielern für die aktuelle Session merken. Dies sieht dann z.B. so aus:
1// @flow
2import {HandlerInput} from 'ask-sdk-core';
3import {Response} from 'ask-sdk-model';
4import {PLAYER_COUNT_KEY, SKILL_NAME} from '../../consts';
5import {deepGetOrDefault} from '../../deepGetOrDefault';
6
7export const StarteSpielIntentHandler = {
8 canHandle(handlerInput: HandlerInput) {
9 return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
10 handlerInput.requestEnvelope.request.intent.name === 'starteSpiel';
11 },
12 handle(handlerInput: HandlerInput): Response {
13 const numberOfPlayers = deepGetOrDefault(handlerInput, '1', 'requestEnvelope', 'request', 'intent', 'slots', 'spieleranzahl', 'value');
14
15 const playersText = numberOfPlayers === '1' ? 'einen' : numberOfPlayers;
16 const speechText = `Prima, ich habe ein neues Spiel für ${playersText} Mitspieler gestartet`;
17 const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
18 const newSessionAttributes = {
19 ...sessionAttributes,
20 [PLAYER_COUNT_KEY]: numberOfPlayers
21 };
22 handlerInput.attributesManager.setSessionAttributes(newSessionAttributes);
23
24 return handlerInput.responseBuilder
25 .speak(speechText)
26 .withSimpleCard(SKILL_NAME, speechText)
27 .withShouldEndSession(false)
28 .getResponse();
29 }
30};
Wir lesen den Wert des Slots spieleranzahl
aus dem Request aus (deepGetOrDefault
ist eine kleine Hilfsmethode, die aus einem Objektbaum beliebiger Tiefe einen Wert ausliest oder einen übergebenen Default-Wert zurückliefert, wenn irgendein Attribut auf dem Pfad zum Wert undefined ist).
Nachdem die Antwort zusammengebaut wurde, merken wir uns diesen Wert in den Session-Attributen und speichern diese mit setSessionAttributes
wieder ab.
In unserem Skill können wir nun einfach einen Intent anbieten, mit dem der Nutzer die aktuelle Anzahl an Mitspielern erfragen kann.
Dies können wir nun bereits sehr gut als Akzeptanztest in cucumber.js formulieren:
# language: de
Funktionalität: Ein Spiel starten
Grundlage:
Angenommen der Anwender hat den Skill geöffnet
Szenario: Ein neues Spiel kann gestartet werden, die Anzahl an Mitspielern wird gespeichert
Wenn der Anwender sagt: Starte ein Spiel mit 4 Spielern
Dann antwortet Alexa mit: Prima, ich habe ein neues Spiel für 4 Mitspieler gestartet
Wenn der Anwender sagt: Wieviele Spieler gibt es
Dann antwortet Alexa mit: Du spielst mit 4 Spielern
Um diesen Test grün zu bekommen, müssen wir nun folgende Dinge tun:
- Wir müssen unser Voice Interaction Model um einen Intent „wieVieleSpieler“ erweitern und mindestens die eine Utterance „Wieviele Spieler gibt es“ ergänzen
- Wir müssen in der Lambda einen
wieVieleSpieler
Handler für diesen Intent programmieren - Und einmalig müssen wir unser Test-Setup so erweitern, dass unser Mock Voice Service die Session-Attribute zwischen zwei Requests korrekt speichert und weitergibt
SetSessionAttributes im Test abfangen
Fangen wir mit dem letzteren Punkt an, diesen müssen wir nur genau einmal machen um unser Testframework für die Behandlung von Session-Attributen zu erweitern.
Im Test müssen wir mitbekommen, wenn ein Intent-Handler die Session-Attribute verändert, damit wir diese dann dem nächsten Step in dem gleichen Test (und somit der gleichen Session) wieder zur Verfügung stellen können. Die Lösung bietet uns ein RequestInterecptor . Mit diesem können wir das handlerInput-Objekt eines Requests bearbeiten, bevor dieses an den Skill weitergegeben wird. Dazu wandeln wir die Skill-Erzeugung in der index.js auf eine Factory-Methode um, der wir optional – für unseren Test – einen RequestInterceptor mitgeben können.
1export const createSkill = (requestInterceptor?: RequestInterceptor) => {
2 const skillBuilder = Alexa.SkillBuilders.standard();
3
4 const skill = skillBuilder
5 .addRequestHandlers(
6 ExitHandler,
7 HelpHandler,
8 LaunchRequestHandler,
9 SessionEndedRequestHandler,
10 StarteSpielIntentHandler,
11 WieVieleSpielerIntentHandler
12 )
13 .addErrorHandlers(ErrorHandler);
14 if (requestInterceptor) {
15 skill.addRequestInterceptors(requestInterceptor);
16 }
17
18 return skill.lambda();
19};
In der index.js die unseren Skill für die produktive Nutzung erzeugt, ist der Aufruf nun ein Einzeiler:
1export const handler = createSkill();
Für den Test ist eine createSkillForTest
Methode vorgeschaltet, die den Skill mit einem RequestInterceptor aufruft:
1function createSkillForTest(world) {
2 const requestInterceptor: RequestInterceptor = {
3 process(handlerInput: HandlerInput) {
4 // Wrap setSessionAttributes, so that we can save these Attributes in the test
5 const orginalSetSessionAttributes = handlerInput.attributesManager.setSessionAttributes;
6 handlerInput.attributesManager.setSessionAttributes = (attributes: Attributes) => {
7 world.sessionAttributes = attributes;
8 orginalSetSessionAttributes.call(handlerInput.attributesManager, attributes);
9 }
10 }
11 };
12 return createSkill(requestInterceptor)
13}
Im RequestInterceptor ersetzen wir die setSessionAttributes
-Methode des attributeManagers durch eine eigene Funktion, die die zu speichernden Attribute im World-Objekt speichert und anschließend die Originalmethode aufruft.
Die executeRequest
-Methode aus dem letzten Blogartikel wird nun um eine Zeile erweitert, die dafür sorgt, dass die SessionAttribute aus dem letzten Request an den nächsten Request weitergegeben werden:
1async function executeRequest(world, skill, request) {
2 return new Promise((resolve) =>; {
3 // Handle session attributes
4 request.session.attributes = simulateDeserialization(world.sessionAttributes);
5 skill(request, {}, (error, result) =>; {
6 world.lastError = error;
7 world.lastResult = result;
8 resolve();
9 });
10 });
11}
Die simulateDeserialization
simuliert dabei eine Serialisierung indem sie den Attributes Key-Value-Store einmal in einen JSON-String und zurück umwandelt. Dies hilft dabei Fehler zu finden, die sonst im Test verdeckt wären: Instanzen einer ES6-Klasse verlieren z.B. (wenn man nichts dagegen tut) bei der Serialisierung ihre Class-Properties .
Implementierung des Handlers für den wieVieleSpieler-Intent
Mit diesem Test-Stetup ist es nun sehr einfach, den neuen Intent test-driven umzusetzen. Die fertige Implementierung findet sich im Repository in Gitlab .
PersistentState Handling
Den State in einer Session halten (und testen) zu können ist gut und wichtig. Wie oben beschrieben, sind Alexa-Sessions aber potenziell recht kurz. Je nach Art des Skill stößt man hier also schnell an seine Grenzen. Zum Glück bietet das Alexa-Skill-Kit-SDK auch eine Möglichkeit, Daten über die Grenzen einer Session hinaus zu persistieren.
Die API für PersistentAttributes ist ähnlich wie die für SessionAttributes:
getPersistentAttributes
liefert die Session-Attribute asynchron als PromisesetPersistentAttributes
setzt die Session-Attribute- und zusätzlich
savePersistentAttributes
erlaubt es, die Session-Attribute asynchron zu persistieren
Damit wir diese API verwenden können, müssen wir dem Skill aber mitteilen, wie wir unsere Daten persistieren wollen. Dafür müssen wir dem Skill bei der Erzeugung einen PersistenceAdapter
mitgeben. Diesen Apdapter können wir entweder anhand des vorgegebenen Interfaces selbst implementieren, oder einen der fertigen Adapter nutzen.
Für den produktiven Betrieb werden wir den fertigen DynamoDbPersistenceAdapter
nutzen, für die Tests implementieren wir unseren eigenen Adapter.
Hinzufügen des DynamoDbPersistenceAdapters
Um den DynamoDB-Adapter verwenden zu können, müssen wir das Paket ask-sdk-dynamodb-persistence-adapter
zu unserem Projekt hinzufügen.
Rechtevergabe für unsere Lambda-Funktion
Außerdem müssen wir unserer Lambda-Funktion Zugriff auf die DynamoDB gewähren. Dazu navigieren wir auf der Lambda Management Console zu unserer Lambda-Funktion und prüfen die Rolle, die unserer Lambda zugewiesen ist:
Diese Rolle suchen wir wiederum in der IAM Management Console um dort der Rolle die Berechtigung für den Zugriff auf die DynamoDB einzuräumen.
Im folgenden Bildschirm suchen wir nach der Richtlinie AmazonDynamoDBFullAccess und fügen diese unserer Rolle hinzu.
Konfiguration des Skills für die Nutzung des DynamoDB Adapters
Um den DynamoD-Adapter nun produktiv nutzen zu können, müssen wir unsere createSkill
-Methode erweitern:
1export const createSkill = (PersistenceAdapterClass: Class, requestInterceptor?: RequestInterceptor) => {
2 const skillBuilder = Alexa.SkillBuilders.custom();
3 const persistenceAdapter = new PersistenceAdapterClass({
4 tableName: `${SKILL_INTERNAL_NAME}_state`,
5 createTable: true
6 });
7 const skill = skillBuilder
8 .addRequestHandlers(
9 ExitHandler,
10 HelpHandler,
11 LaunchRequestHandler,
12 SessionEndedRequestHandler,
13 StarteSpielIntentHandler,
14 WieVieleSpielerIntentHandler
15 )
16 .withPersistenceAdapter(persistenceAdapter)
17 .addErrorHandlers(ErrorHandler);
18 if (requestInterceptor) {
19 skill.addRequestInterceptors(requestInterceptor);
20 }
21
22 return skill.lambda();
23};
Die Methode bekommt nun einen weiteren Parameter: PersistenceAdapterClass
. Wir übergeben also eine Klasse, die einen PersistenceAdapter implementiert. Diese Klasse wird dann in createSkill instanziert (wobei der Name der Tabelle in der DynamoDB festgelegt wird) und mit withPersistenceAdapter
an den SkillBuilder gegeben.
Um die withPersistenceAdapter-Methode des SkillBuilders nutzen zu können, mussten wir den Skill Builder (am Anfang der Methode) auf einen custom SkillBuilder umstellen.
In der index.js übergeben wir als Parameter für die PersistenceAdapaterClass nun den DynamoDB-Adapter:
1import {createSkill} from './createSkill'; 2import {DynamoDbPersistenceAdapter} from 'ask-sdk-dynamodb-persistence-adapter'; 3 4export const handler = createSkill(DynamoDbPersistenceAdapter);
Konfiguration des Skills für die Nutzung unseres eigenen PersistenceAdapaters im Test
Im Test übergeben wir als Parameter für die PersistenceAdapaterClass unsere eingene Implementierung:
1function createSkillForTest(world) {
2 class MockPersistenceAdapterClass {
3 getAttributes: () => Promise;
4 saveAttributes: () => Promise;
5 attributes: any;
6
7 constructor() {
8 this.getAttributes = () => Promise.resolve(world.persistentAttributes);
9 this.saveAttributes = (_, attributes) => {
10 world.persistentAttributes = attributes;
11 return Promise.resolve();
12 };
13 }
14 }
15 const requestInterceptor: RequestInterceptor = {
16 process(handlerInput: HandlerInput) {
17 // Wrap setSessionAttributes, so that we can save these Attributes in the test
18 const orginalSetSessionAttributes = handlerInput.attributesManager.setSessionAttributes;
19 handlerInput.attributesManager.setSessionAttributes = (attributes: Attributes) => {
20 world.sessionAttributes = attributes;
21 orginalSetSessionAttributes.call(handlerInput.attributesManager, attributes);
22 }
23 }
24 };
25 return createSkill(MockPersistenceAdapterClass, requestInterceptor)
26}
Ähnlich wie für die SessionAttributes speichern wir auch die PersistentAttributes im globalen World-Objekt. Dort werden sie auch aktualisiert, wenn der Skill diese mit saveAttributes speichert.
Fazit
Mit diesem Setup haben wir nun eine schöne Grundlage für Akzeptanztest-getriebene Entwicklung von Alexa-Skills gelegt. Den vollständigen Source-Code findet ihr auf Gitlab. Der Stand für diesen Blogpost ist getagged unter: https://gitlab.com/spittank/fuenferpasch/tree/BlogPart5_AttributeHandling.
Im Repo sind auch noch weitere cucumber-Steps implementiert, z.B. zur Überprüfung von Bildschirmausgaben für Echos mit Display, oder zur Dialogsteuerung, wenn ein Nutzer einen Intent ohne einen erforderlichen Wert für einen Slot aufruft.
Lasst mir gerne Feedback hier, wenn ihr eigene / weitere Anwendungsfälle für die Skillentwicklung habt.
Weitere Beiträge
von Stefan Spittank
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Stefan Spittank
User Experience Specialist
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.