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 attributessetSessionAttributes
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:
- The new intent (and at least one utterance for it) needs to be added to our voice interaction model.
- We need to add a new handler for this intent to our Lambda function
- 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 promisesetPersistenAttributes
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.
More articles
fromStefan Spittank
Your job at codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
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.
Blog author
Stefan Spittank
User Experience Specialist
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.