BDD für Alexa Skills – Teil 5: cucumber.js Tests und State-Handling

Keine Kommentare

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-Attribute
  • setSessionAttributes 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:

// @flow
import {HandlerInput} from 'ask-sdk-core';
import {Response} from 'ask-sdk-model';
import {PLAYER_COUNT_KEY, SKILL_NAME} from '../../consts';
import {deepGetOrDefault} from '../../deepGetOrDefault';
 
export const StarteSpielIntentHandler = {
    canHandle(handlerInput: HandlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest' &&
            handlerInput.requestEnvelope.request.intent.name === 'starteSpiel';
    },
    handle(handlerInput: HandlerInput): Response {
        const numberOfPlayers = deepGetOrDefault(handlerInput, '1', 'requestEnvelope', 'request', 'intent', 'slots', 'spieleranzahl', 'value');
 
        const playersText = numberOfPlayers === '1' ? 'einen' : numberOfPlayers;
        const speechText = `Prima, ich habe ein neues Spiel für ${playersText} Mitspieler gestartet`;
        const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
        const newSessionAttributes = {
            ...sessionAttributes,
            [PLAYER_COUNT_KEY]: numberOfPlayers
        };
        handlerInput.attributesManager.setSessionAttributes(newSessionAttributes);
 
        return handlerInput.responseBuilder
            .speak(speechText)
            .withSimpleCard(SKILL_NAME, speechText)
            .withShouldEndSession(false)
            .getResponse();
    }
};

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:

  1. Wir müssen unser Voice Interaction Model um einen Intent „wieVieleSpieler“ erweitern und mindestens die eine Utterance „Wieviele Spieler gibt es“ ergänzen
  2. Wir müssen in der Lambda einen wieVieleSpieler Handler für diesen Intent programmieren
  3. 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.

export const createSkill = (requestInterceptor?: RequestInterceptor) => {
    const skillBuilder = Alexa.SkillBuilders.standard();
 
    const skill = skillBuilder
        .addRequestHandlers(
            ExitHandler,
            HelpHandler,
            LaunchRequestHandler,
            SessionEndedRequestHandler,
            StarteSpielIntentHandler,
            WieVieleSpielerIntentHandler
        )
        .addErrorHandlers(ErrorHandler);
    if (requestInterceptor) {
        skill.addRequestInterceptors(requestInterceptor);
    }
 
    return skill.lambda();
};

In der index.js die unseren Skill für die produktive Nutzung erzeugt, ist der Aufruf nun ein Einzeiler:

export const handler = createSkill();

Für den Test ist eine createSkillForTest Methode vorgeschaltet, die den Skill mit einem RequestInterceptor aufruft:

function createSkillForTest(world) {
    const requestInterceptor: RequestInterceptor = {
        process(handlerInput: HandlerInput) {
            // Wrap setSessionAttributes, so that we can save these Attributes in the test
            const orginalSetSessionAttributes = handlerInput.attributesManager.setSessionAttributes;
            handlerInput.attributesManager.setSessionAttributes = (attributes: Attributes) => {
                world.sessionAttributes = attributes;
                orginalSetSessionAttributes.call(handlerInput.attributesManager, attributes);
            }
        }
    };
    return createSkill(requestInterceptor)
}

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:

async function executeRequest(world, skill, request) {
    return new Promise((resolve) =>; {
        // Handle session attributes
        request.session.attributes = simulateDeserialization(world.sessionAttributes);
        skill(request, {}, (error, result) =>; {
            world.lastError = error;
            world.lastResult = result;
            resolve();
        });
    });
}

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 Promise
  • setPersistentAttributes 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:
Screenshot: Ermittlung der ausführenden Rolle in der Lambda Management Console

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.

Richtlinie zur AWS Rolle hinzufügen

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:

export const createSkill = (PersistenceAdapterClass: Class, requestInterceptor?: RequestInterceptor) => {
    const skillBuilder = Alexa.SkillBuilders.custom();
    const persistenceAdapter = new PersistenceAdapterClass({
        tableName: `${SKILL_INTERNAL_NAME}_state`,
        createTable: true
    });
    const skill = skillBuilder
        .addRequestHandlers(
            ExitHandler,
            HelpHandler,
            LaunchRequestHandler,
            SessionEndedRequestHandler,
            StarteSpielIntentHandler,
            WieVieleSpielerIntentHandler
        )
        .withPersistenceAdapter(persistenceAdapter)
        .addErrorHandlers(ErrorHandler);
    if (requestInterceptor) {
        skill.addRequestInterceptors(requestInterceptor);
    }
 
    return skill.lambda();
};

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:

import {createSkill} from './createSkill';
import {DynamoDbPersistenceAdapter} from 'ask-sdk-dynamodb-persistence-adapter';
 
export 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:

function createSkillForTest(world) {
    class MockPersistenceAdapterClass {
        getAttributes: () => Promise;
        saveAttributes: () => Promise;
        attributes: any;
 
        constructor() {
            this.getAttributes = () => Promise.resolve(world.persistentAttributes);
            this.saveAttributes = (_, attributes) => {
                world.persistentAttributes = attributes;
                return Promise.resolve();
            };
        }
    }
    const requestInterceptor: RequestInterceptor = {
        process(handlerInput: HandlerInput) {
            // Wrap setSessionAttributes, so that we can save these Attributes in the test
            const orginalSetSessionAttributes = handlerInput.attributesManager.setSessionAttributes;
            handlerInput.attributesManager.setSessionAttributes = (attributes: Attributes) => {
                world.sessionAttributes = attributes;
                orginalSetSessionAttributes.call(handlerInput.attributesManager, attributes);
            }
        }
    };
    return createSkill(MockPersistenceAdapterClass, requestInterceptor)
}

Ä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.

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.