43

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

 5 years ago
source link: https://www.tuicool.com/articles/hit/6zqErqJ
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

No Comments

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
setSessionAttributes

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:

// @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 {t} = handlerInput.attributesManager.getRequestAttributes();
 
        const playersText = numberOfPlayers === '1' ? 'einen' : numberOfPlayers;
        const speechText = t('GAME_STARTED', playersText);
        const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
        sessionAttributes[PLAYER_COUNT_KEY] = numberOfPlayers;
        handlerInput.attributesManager.setSessionAttributes(sessionAttributes);
 
        return handlerInput.responseBuilder
            .speak(speechText)
            .withSimpleCard(t('SKILL_NAME'), speechText)
            .withShouldEndSession(false)
            .getResponse();
    }
};

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:

export const createSkill = (requestInterceptors: RequestInterceptor[]) => {
    const skillBuilder = Alexa.SkillBuilders.custom();
    const skill = skillBuilder
        .addRequestHandlers(
            ExitHandler,
            HelpHandler,
            LaunchRequestHandler,
            SessionEndedRequestHandler,
            StarteSpielIntentHandler
        )
        .withPersistenceAdapter(persistenceAdapter)
        .addErrorHandlers(ErrorHandler);
    if (requestInterceptors.length > 0) {
        skill.addRequestInterceptors(...requestInterceptors);
    }
 
    return skill.lambda();
};

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

export 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:

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

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:

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();
        });
    });
}

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
setPersistenAttributes
savePersistentAttributes

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:

6NJjMjq.png!web

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

ZZvIjmU.png!web

Configuration of our skill to use the PersistenceAdapter

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

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

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:

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

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

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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK