Interactions

An "interaction" is one of the five building blocks of the Screenplay Pattern; a low-level activity, directly exercising the actor's ability to interact with a specific external interface of the system under test or perform a side-effect. Such external interface could be a website, a mobile app, a web service, but also a file system, a database, or pretty much anything else a Node.js program can integrate with.

The Screenplay Pattern
The Screenplay Pattern

Implementing Interactions

Serenity/JS modules provide you with dozens of interactions you can use in your automated acceptance tests. However, if you ever need to implement a custom interaction, there are two ways you can go about it.

For the sake of argument, let's say that we wanted to enable our actor to write arbitrary messages to an output stream, such as a file or a computer terminal, and that we already have created a custom ability that took care of the low-level integration:

import WriteStream = NodeJS.WriteStream;
import { Ability, UsesAbilities } from '@serenity-js/core';

export class WriteToStream implements Ability {
    static of(stream: WriteStream) {
        return new WriteToStream(stream);
    }

    static as(actor: UsesAbilities): BrowseTheWeb {
        return actor.abilityTo(WriteToStream);
    }

    constructor(private readonly stream: WriteStream) {
    }

    printLine(parts: string) {
        this.stream.write(message + '\n');
    }
}

With this simple "ability" in place, let's now look into the different ways we could implement an interaction exercising it.

PRO TIP: If you'd like the actor to print messages to your computer terminal, consider using the built-in Log interaction.

An Interaction.where...

The Interaction.where factory method provides an easy pattern to define a custom interaction:

import { Interaction } from '@serenity-js/core';

const GreetTheWorld = () =>
    Interaction.where(`#actor greets the world`, actor =>
        WriteToStream.as(actor).printLine(`Hello World!`)
    );

As you can see, there are only three things you need to define an interaction:

  • the name of the function that will create the interaction, in this case GreetTheWorld,
  • the description of the interaction - #actor greets the world, which will be used when reporting the interaction,
  • the interaction body - a function which takes an actor and uses their ability to WriteToStream to perform a call to an external system or perform a side effect, and returns a void or a Promise<void>.

PRO TIP: The #actor part of the description will get replaced by the actor name when the interaction is reported.

An interaction defined using the Interaction.where factory method can be then given to an actor to perform:

import { Actor } from '@serenity-js/core';

const actor = Actor.named('Ernest').whoCan(WriteToStream.of(process.stdout));

actor.attemptsTo(
    GreetTheWorld(),
);

Extending Interaction

Another pattern you can follow when designing a custom interaction is extending the base Interaction class.

This is useful if the interaction you're designing requires helper methods to convert the data from one format to another, or you'd like to implement a builder pattern to simplify how the interaction is configured.

import { AnswersQuestions, Interaction, Question, UsesAbilities } from '@serenity-js/core';

class Greet extends Interaction {
    static all(...names: string[]) {
        return new Greet(names);
    }

    constructor(private readonly names: string[]) {
        super();
    }

    performAs(actor: UsesAbilities & AnswersQuestions): PromiseLike<void> {
        return Promise.resolve(
            WriteToStream.as(actor).printLine(`Hello ${ this.join(this.names) }!`))
        );
    }

    toString(): string {
        return `#actor greets ${ this.join(this.names) }`;
    }

    private join(names: string[]) {
        return `${ names.slice(0, -1).join(', ') } and ${ a.slice(-1) }`;
    }
}

Similarly to the factory method approach, the inheritance approach requires you to specify:

  • the name of the interaction - as the name of the class, here: Greet,
  • the body of the interaction - as an implementation of the performAs method,
  • the description of the interaction - as an implementation of the toString method.

However, the inheritance approach allows you to add helper methods, such as join in the example above.

PRO TIP: All Serenity/JS interactions must extend the base Interaction class to allow for the framework to correctly distinguish them from other activities, such as tasks.

In the above example, the interaction to Greet could be given to an actor to perform as follows:

import { Actor } from '@serenity-js/core';

const actor = Actor.named('Ernest').whoCan(WriteToStream.of(process.stdout));

actor.attemptsTo(
    Greet.all('Alice', 'Bob', 'Cindy'),
);

Performing this interaction will result in the actor printing Hello Alice, Bob and Cindy! to the output stream.

PRO TIP: Interactions extending the base Interaction class have to return a Promise<void>.
Interactions created using Interaction.where can return either Promise<void> or void.

Single Responsibility Principle

Serenity/JS interactions are named using the vocabulary of the solution domain, so: "Click on a button", "Enter password into a form field" or "Send a request", and are focused on doing one thing and one thing only.

If you're considering implementing an interaction that performs more than one logical activity (i.e. checks if the button is visible and then clicks on it if is), consider implementing separate interactions for separate responsibilities and composing them together using a task.