Beliebte Suchanfragen
|
//

Behaviour-driven development (BDD) of an Alexa Skill with Cucumber.js – Part 2

11.3.2019 | 9 minutes of reading time

In the first post of this blog series we established a framework to easily write acceptance tests for an Alexa Skill in Cucumber.js. This second part will be about enhancing this framework, so that our skill is able to use state handling.

Ways to store skill state information

Amazon offers us three ways to store information about the state of our skill:

  • For the duration of the current request
  • For the duration of the current session
  • Persistent across sessions

For the latter one our skill will need a special persistence adapter. Amazon offers a prebuilt DynamoDB-based adapter which is easily configured.

For our skill, the storage of information just for the current request is not sufficient. So we’ll skip this one. But the storage of information for the duration of one session looks more promising. So let’s start with this.

Session-state handling

What is an Alexa session?

Before we go into the details of storing information for one session, let’s take a step back and look at what the term “session” means in the context of an Alexa Skill.

  • The moment a user opens our skill, a new session is initiated
  • A session will end if
    • the user explicitly ends the session (e.g. by calling “Alexa, stop”).
    • the user doesn’t continue the conversation after our last response within eight seconds
    • We can enhance this time frame by another eight seconds if we provide a reprompt in our response
    • The session will be automatically terminated if an error occurs during the handling of the intent

As we can see, an Alexa session is tightly coupled to the user.

As we can also see, the duration of an Alexa session is strongly dependent on the user continously interacting with our skill (ans so potentially quite short). At least that’s the case for custom skills, music or video skills have different interaction models.

Session attributes

For the duration of an Alexa session, we can store the current state in session attributes. We get the session attributes as a key value store from the AttributeManager provided by the handlerInput object.

The AttributeManager offers us two methods to get and set the session attributes:

  • getSessionAttributes to read the attributes
  • setSessionAttributes to write the attributes

We can use the session attributes in our skill, to store the given number of players for the current session. In our starteSpiel (startGame) intent, this looks like this:

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 {t} = handlerInput.attributesManager.getRequestAttributes();
16 
17        const playersText = numberOfPlayers === '1' ? 'einen' : numberOfPlayers;
18        const speechText = t('GAME_STARTED', playersText);
19        const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
20        sessionAttributes[PLAYER_COUNT_KEY] = numberOfPlayers;
21        handlerInput.attributesManager.setSessionAttributes(sessionAttributes);
22 
23        return handlerInput.responseBuilder
24            .speak(speechText)
25            .withSimpleCard(t('SKILL_NAME'), speechText)
26            .withShouldEndSession(false)
27            .getResponse();
28    }
29};

We start by reading the number of players (given by the user) from the request (deepGetOrDefault is a small utility function which will either return the value of an object attribute on a given path in a nested object structure, or return the default value if any of the given attributes is null or undefined).

Then we build our response. Instead of hard-coded strings, we use the function t which is a translation function provided by i18-next, the framework we’re using for internationalization. The function t will be injected by the localizationInterceptor. You can find more details about localizing Alexa Skills in this blog post .

After creating the response text, we get the sessionAttributes, store our number of players in it and save the attributes.

Finally the response object is built and returned.

We can now use this session attribute to provide another intent to retrieve the number of players later.

To do this, we’ll start with a new acceptance test (since we want to work in true BDD fashion):


# language: en
Feature: Start a new game
  Background:
    Given the user has opened the skill

  Scenario: A new game can be started, the number of players is stored
    When the user says: Start a new game with 4 players
    Then Alexa replies with: Okay, I started a new game for 4 players
    When the user says: How many players do I have?
    Then Alexa replies with: You are playing with 4 players

To get this test green (= passing), we have to do the following things:

  1. The new intent (and at least one utterance for it) needs to be added to our voice interaction model.
  2. We need to add a new handler for this intent to our Lambda function
  3. We have to enhance our testing framework, so that session attributes will correctly be handled and new intents within the same session receive the previously stored attributes.

Handling SetSessionAttributes in Cucumber tests

We start with the last point, since this is a one-time-only investment.

Our test framework needs to be aware of changes to the session attributes, so that they are available for further requests. To be able to do this, we use a requestInterceptor . The interceptor allows us to modifiy the handlerInput object before it is given to the handler’s handle function.

To inject the requestInterceptor, we change the skill creation to a factory method. This factory method receives a list of interceptors which will be added to our Lambda:

1export const createSkill = (requestInterceptors: RequestInterceptor[]) => {
2    const skillBuilder = Alexa.SkillBuilders.custom();
3    const skill = skillBuilder
4        .addRequestHandlers(
5            ExitHandler,
6            HelpHandler,
7            LaunchRequestHandler,
8            SessionEndedRequestHandler,
9            StarteSpielIntentHandler
10        )
11        .withPersistenceAdapter(persistenceAdapter)
12        .addErrorHandlers(ErrorHandler);
13    if (requestInterceptors.length > 0) {
14        skill.addRequestInterceptors(...requestInterceptors);
15    }
16 
17    return skill.lambda();
18};

The production skill is still created in the index.js file. The call is quite simple:

1export const handler = createSkill([localizationInterceptor]);

We only provide one interceptor object (the internationalization interceptor mentioned above).

The skill for our tests will be created by a new createSkillForTest method:

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([localizationInterceptor, requestInterceptor])
13}

For the tests we provide an additional interceptor object we create locally. We use the interceptor to replace the setSessionAttributes of the AttributeManager given to our handler. The new setSessionAttributes stores the sessionAttributes in the global world object of Cucumber.js and afterwards calls the original setSessionAttributes method (we previously replaced).

So we already took care of the first part (receiving the changed session attributes), now we need to make sure that the next request handler will receive the updated attributes. This is done in the executeRequest method introduced in the last part of this blog series. We add an additional lines to take care of the sessionAttributes:

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}

The simulateDeserialization simulates a (de-)serialization by converting the key-value store to JSON and parsing it back to a JavaScript object. This helps find bugs with ES6 Class properties, which will be lost in this process (if you don’t manually restore them ).

Implementing the new intent

With our updated test framework, it is quite easy to implement the final intent handler. You can see the implementation in the repository on gitlab .

PersistentState handling

To be able to store a state in a session is important and nice. But as we’ve seen above, an Alexa session is potentially quite short-lived. Depending on the type of skill we are implementing, this may soon become an issue. Luckily the Alexa Skill Kit SDK allows us to persist data beyond the duration of a session.

The API to use PersistenAttributes is very similar to SessionAttributes:

  • getPersistentAttributes will asynchronously return the persistent attributes in a promise
  • setPersistenAttributes will set the persistent attributes
  • and savePersistentAttributes will asynchronously persist the attributes previously set

To be able to use this API, we need to tell our skill how the data is being persisted. We do this by configuring our skill with a PersistenceAdapter. We could implement our own PersistenceAdapter or use one of the predefined ones .

For our skill we’ll be using the DynamoDbPersistenceAdapter, only in the tests we’ll implement our own.

Adding the DynamoDbPersistenceAdapter

To use the DynamoDbPersistenceAdapter, we have to add the package ask-sdk-dynamodb-persistence-adapter to our project.

Giving the Lambda function access to the DynamoDB

Additionally our Lambda function needs access rights to read from the database. To do this, we need to look up the role assigned to our Lambda function first. We do this from the Lambda Management Console from where we navigate to our Lambda function to look up the role:

We use this role in the IAM Management Console to assign access rights to the DynamoDB to this role:

Configuration of our skill to use the PersistenceAdapter

To use the DynamoDB Persistence Adapter, we need to enhance our createSkill function:

1export const createSkill = (PersistenceAdapterClass: Class<PersistenceAdapter>, requestInterceptors: 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 (requestInterceptors.length > 0) {
19        skill.addRequestInterceptors(...requestInterceptors);
20    }
21 
22    return skill.lambda();
23};

The method receives an additional parameter: PersistenceAdapterClass. This is the class implementing the PersistenceAdapter interface. Our method creates a new instance of this class (configuring the table name of the table we store our state into) and passes this instance to the skill builder using the withPersistenceAdapter method.

From our index.js we pass in an DynamoDB adapter to this new parameter:

1import {createSkill} from './createSkill';
2import {DynamoDbPersistenceAdapter} from 'ask-sdk-dynamodb-persistence-adapter';
3import {localizationInterceptor} from './localizationInterceptor';
4 
5export const handler = createSkill(DynamoDbPersistenceAdapter, [localizationInterceptor]);

Configuration of the PersistenceAdapter in our tests

For the Cucumber tests we pass in our own implementation of a PersistenceAdapter:

1function createSkillForTest(world) {
2    class MockPersistenceAdapterClass {
3        getAttributes: () => Promise<any>;
4        saveAttributes: () => Promise<void>;
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, [localizationInterceptor, requestInterceptor])
26}

Similar to the way we’re handling session attributes, we store the persistent attributes in the global world object and update them there if somewhere in the lambda function saveAttributes is called.

Conclusion

The test framework developed in the blog series is the base to be able to do behaviour-driven development of Alexa Skills. You will find the complete source code on GitLab. I created tags for different blog posts, so after this blog post you’ll probably want to look at:

https://gitlab.com/spittank/fuenferpasch/tree/BlogBDD_EN_Part2

first.

Do you have additional use cases not covered yet? Is this framework helpful for you? I would love to hear your feedback in the comments below.

|

share post

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

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.