Unit Testing

The Jovo TestSuite allows you to create and run unit tests that work across voice and chat platforms.

Introduction

Unit Testing is a testing method that helps you make sure individual units of software work as expected. This way you don't have to manually test every potential interaction of your Jovo app after any change you do to the code.

The Jovo TestSuite builds on top of Jest, a popular Javascript testing framework. It offers a set of features that helps you test your Jovo apps, both individual interactions as well as full conversational sequences.

Each Jovo project comes with a test folder and at least a sample.test.ts file that looks like this:

import { TestSuite, InputType } from '@jovotech/framework';

const testSuite = new TestSuite();

test('should ask the user if they like pizza', async () => {
  const { output } = await testSuite.run({
    type: InputType.Launch, // or 'LAUNCH'
  });

  expect(output).toEqual([
    {
      message: 'Do you like pizza?',
      quickReplies: ['yes', 'no'],
      listen: true,
    },
  ]);
});

To run all tests, use the following command in your terminal:

$ npm test

In the following sections, we're going to dive deeper into writing unit tests for the Jovo Framework. First, we're going to take a look at the structure of a unit test. After that, we'll dive into the TestSuite configuration.

The TestSuite is based on the RIDR lifecycle and allows you to either test native platform request and response objects as well as the abstracted Jovo $input and $output properties. Learn more in the different ways of testing section.

For more advanced testing, we will then take a look at sequences and context.

Structure of a Unit Test

Unit test files are usually located in a test folder of your Jovo project and are called <name>.test.ts.

Here is a full example of a test file with one unit test:

import { TestSuite, InputType } from '@jovotech/framework';

const testSuite = new TestSuite();

test('should ask the user if they like pizza', async () => {
  const { output } = await testSuite.run({
    type: InputType.Launch, // or 'LAUNCH'
  });

  expect(output).toEqual([
    {
      message: 'Do you like pizza?',
      quickReplies: ['yes', 'no'],
      listen: true,
    },
  ]);
});

A test file consists of the following elements:

  • TestSuite initialization: The TestSuite gets configured here.
  • test(): Each unit test is added as a callback function inside this method.
  • run(): This method takes either an input object or a native request object and returns output and a native response.
  • expect(): This and other Jest methods are used to test if the result from run() looks as expected.

TestSuite Initialization

The TestSuite is used to simulate a conversational request-response lifecycle that can then be tested using the Jest expect() method.

In most cases, the suite is initialized globally before all tests:

import { TestSuite } from '@jovotech/framework';
// ...

const testSuite = new TestSuite({
  /* options */
});

It is also possible to initialize the TestSuite inside a describe block. This way, you can have differing configurations for different groups of tests. Learn more about describe in the official Jest docs.

describe(`...` , async () => {

  const testSuite = new TestSuite({ /* options */ });

  test('first ...', async () => { /* ... */ });
  test('second ...', async () => { /* ... */ });
  // ...
}

There are various options like platform that can be added to the constructor. Learn more in the TestSuite configuration section.

test

A test file is separated into multiple unit tests that are all defined using a test() method. The first parameter is the name of the test (which will be displayed when executing the tests) and the second is a callback function that includes the test logic. Learn more about test() in the official Jest docs.

test('should ...', async () => {
  // ...
});

If you want to group tests, you can also use describe. Learn more about describe in the official Jest docs.

describe(`...` , async () => {

  test('first ...', async () => { /* ... */ });
  test('second ...', async () => { /* ... */ });
  // ...
}

run

The run() method either takes an input object or a request, executes the RIDR lifecycle, and returns both output and a response:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

test('should ask the user if they like pizza', async () => {
  const testSuite = new TestSuite();

  const { output, response } = await testSuite.run({
    type: InputType.Launch, // or 'LAUNCH'
  });

  // ...
});

The following results are returned by run():

  • output: The Jovo $output array as the result of the handler execution. Useful for cross-platform testing.
  • response: The native platform response that the $output gets translated to. Useful for platform-specific testing.

It is also possible to test sequences by either using the run() method multiple times or by passing an array. The below example first simulates a LAUNCH input and then a YesIntent:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

const testSuite = new TestSuite();

test('should respond in a positive way if user likes pizza', async () => {
  const { output } = await testSuite.run([
    {
      type: InputType.Launch,
    },
    {
      intent: 'YesIntent',
    },
  ]);

  // ...
});

Learn more in the sequence testing section.

expect

The output and response from run() can be tested using expect().

Below is an example that tests if the resulting output equals an output template:

test('should ask the user if they like pizza', async () => {
  // ...

  expect(output).toEqual([
    {
      message: 'Hello World! Do you like pizza?',
    },
  ]);
});

Jest offers many methods like toEqual in the example above. Learn more in the official Jest docs.

For example, you can also test output elements like this:

test('should accept an input object, should return an output object', async () => {
  // ...

  expect(output).toHaveLength(1);
  expect(output[0].message).toBeDefined();
  expect(output[0].message).toMatch('Hello World! Do you like pizza?');
});

TestSuite Configuration

When the TestSuite gets initialized, you can add configuration options to the constructor:

import { TestSuite } from '@jovotech/framework';
// ...

const testSuite = new TestSuite({
  /* options */
});

The following options are available:

  • platform: The platform (e.g. Alexa) the TestSuite should simulate.
  • userId: The user ID that should be used. Default: Random user ID.
  • locale: The language that should be used. Default: en.
  • data: Configurations for data persistence. Default: Delete data after each test.
  • stage: Which app stage should be used. Default: dev.
  • app: If you want to build your own app instance and pass it to the TestSuite, you can do it here.

platform

The platform property accepts a constructor of a Jovo platform integration.

Here is an example for Alexa:

import { AlexaPlatform } from '@jovotech/platform-alexa';

// ...

const testSuite = new TestSuite({ platform: AlexaPlatform });

If you want to test multiple platforms, you can use a for loop like this:

import { TestSuite } from '@jovotech/framework';
import { AlexaPlatform } from '@jovotech/platform-alexa';
import { GoogleAssistantPlatform } from '@jovotech/platform-googleassistant';

// ...

for (const Platform of [AlexaPlatform, GoogleAssistantPlatform]) {
  const testSuite = new TestSuite({ platform: Platform });

  test('should...', async () => {
    /* ... */
  });
  test('should...', async () => {
    /* ... */
  });
}

userId

If you want to define your own user IDs for unit tests, you can do so using the userId property:

const testSuite = new TestSuite({ userId: 'test-user' });

By default, a random user ID will be generated.

locale

The locale property lets you define which language should be tested:

const testSuite = new TestSuite({ locale: 'en' });

The default value is en.

data

The TestSuite instance stores different types of data like session data and user data in-memory, so there is no database integration needed to test data persistence.

By default, Jovo deletes the data after each test to make sure that there are no side effects between tests.

You can also modify this behavior in the data property:

const testSuite = new TestSuite({
  data: {
    deleteAfterEach: true;
    deleteAfterAll: true;
  }
});

The properties follow the Jest setup and teardown conventions:

  • deleteAfterEach: Data should be deleted after each test.
  • deleteAfterAll: Data should be deleted after all tests in this scope, which means either after all tests in this describe group or after all global tests.

stage

The stage property defines which app configuration should be used for the unit tests. Learn more about staging here.

const testSuite = new TestSuite({ stage: 'dev' });

By default, dev will be used.

app

Potentially, there are different ways how you can set up the Jovo app instance. Learn more about app configuration here.

For more advanced or custom use cases, the app property allows you to build your own instance and pass it to the TestSuite.

import app from '../src/myApp';

// ...

const testSuite = new TestSuite({ app });

By default, the Jovo app.ts and the stage defined in stage will be used.

Different Ways of Testing

The TestSuite utilizes the RIDR lifecycle and potentially involves all the RIDR properties:

  • Request: The native JSON request being sent by a platform.
  • Input: An abstracted object that contains structured data that is derived from a request.
  • Output: An array that contains structured output objects.
  • Response: The native JSON response that is returned to the platform.

The TestSuite's run() method method accepts either a request or an input object, and returns both output and a response:

const { output, response } = await testSuite.run(/* request or input */);

We recommend using input and output for most flows that don't rely on any heavy platform-specific features. For flows that go beyond that, you can use request and response.

Request

You can use JSON requests to test the flow in the same way as if a platform sends a request to your app. For most use cases, we recommend input testing.

Below, you can find an example that imports a request JSON file and passes it to the run() method:

import CustomRequest from './requests/CustomRequest';
// ...

test('should accept a custom request', async () => {
  // ...

  const { output, response } = await testSuite.run(CustomRequest);

  // ...
});

Input

The Jovo $input property contains structured data that is derived from a request. Since it is an abstracted object that works across platforms, we recommend using the input object for running unit tests for most use cases. Learn more about the $input property here.

You can pass an input object to the run() method like this:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

test('should ask the user if they like pizza', async () => {
  // ...

  const { output, response } = await testSuite.run({
    type: InputType.Launch, // or 'LAUNCH'
  });

  // ...
});

By default, the input is of the type INTENT. You can add intents and entities to the object like this:

const { output, response } = await testSuite.run({
  intent: 'MyNameIsIntent',
  entities: {
    name: {
      value: 'Max',
    },
  },
});

You can find all input types and properties in the $input documentation.

Output

The $output array is assembled by the handlers that return output using the $send() method. This structured output is then turned into a native platform response. Learn more about the $output property here.

Since the $output property is an array of abstracted output templates that work across platforms, we recommend using this method over response testing for most use cases.

For example, you can use toEqual() (see the expect() section above) to test if the tested and desired output arrays match:

const { output } = await testSuite.run(/* request or input */);

expect(output).toEqual([
  {
    message: 'Hello World! Do you like pizza?',
  },
]);

Response

As part of the RIDR lifecycle, the $output array is turned into a native platform response which is then returned to the platform.

We recommend testing with output for most flows. For experiences that heavily rely on platform-specific response features, you can also test the response object. Below, you can find an example for Alexa:

import { TestSuite } from '@jovotech/framework';
import { AlexaPlatform } from '@jovotech/platform-alexa';

test('should accept an Alexa request, should return an Alexa response', async () => {
  const testSuite = new TestSuite({ platform: AlexaPlatform });
  // ...

  const { response } = await testSuite.run(/* request or input */);

  expect(response.hasSessionEnded()).toBeFalsy();
  expect(response.response.outputSpeech).toBeDefined();
  expect(response.response.outputSpeech!.ssml).toMatch(
    '<speak>Hello World! Do you like pizza?</speak>',
  );
});

The TestSuite automatically infers the types depending on the platform. In the example above, response is of type AlexaResponse.

Test Sequences

For conversations that include multiple interactions, you can use the run() method multiple times in one test:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

const testSuite = new TestSuite();

test('should respond in a positive way if user likes pizza', async () => {
  // First interaction
  await testSuite.run({
    type: InputType.Launch,
  });

  // Second interaction
  const { output } = await testSuite.run({
    intent: 'YesIntent',
  });

  // ...
});

You can also pass an array to the run() method:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

const testSuite = new TestSuite();

test('should respond in a positive way if user likes pizza', async () => {
  const { output } = await testSuite.run([
    {
      type: InputType.Launch,
    },
    {
      intent: 'YesIntent',
    },
  ]);

  // ...
});

If you want to test the steps along the way, you can also do something like this:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

const testSuite = new TestSuite();

test('should respond in a positive way if user likes pizza', async () => {
  // First interaction
  const { output: launchOutput } = await testSuite.run({
    type: InputType.Launch,
  });
  expect(launchOutput).toEqual([
    {
      message: 'Hello World! Do you like pizza?',
    },
  ]);

  // Second interaction
  const { output: yesOutput } = await testSuite.run({
    intent: 'YesIntent',
  });
  expect(yesOutput).toEqual([
    {
      message: 'Yes! I love pizza, too.',
    },
  ]);
});

Test Context

The TestSuite allows you to access Jovo properties in order to modify certain elements before the run() method is executed.

This is especially relevant for different types of data, for example user data that needs to be set before running a test:

import { TestSuite, InputType } from '@jovotech/framework';
// ...

const testSuite = new TestSuite();

test('...', async () => {
  testSuite.$user.data = {
    /* ... */
  };

  // ...
});

Currently, you can modify the following properties that will be merged into the jovo instance:

  • testSuite.$user.data
  • testSuite.$session
  • testSuite.$request