Upgrading to Serenity/JS 2.0

If you wish to upgrade your existing Serenity/JS 1.x project to use version 2.0 of the framework - this guide is for you!

The upgrade path is relatively straightforward as the main Screenplay classes, such as Actor, Task, Interaction and Question are backwards compatible. However, the latest version of Serenity/JS introduces a number of significant new features, which will give you an opportunity to simplify your existing code.

Need a hand?

If you'd like some help with the upgrade or a review of your code base - check out the support options.

Project templates and examples

If you'd like to see Serenity/JS 2.0 in action, watch my talk on "Full-stack acceptance testing with Serenity/JS 2.0".

If you prefer to dive straight into the code, there are several example mini-projects within the Serenity/JS main repo that demonstrate some of the major features, as well as integrations and test runners the framework supports.

In particular, you might be interested to check out:

If you're starting a new project, the easiest way to do it is to use one of the Serenity/JS project templates as the foundation.

The most popular templates include:

A modular framework

The two main Serenity/JS 1.x modules you have probably interacted with the most were serenity-js, which contained the bulk of the framework, and serenity-cli - a wrapper around the Serenity BDD CLI, responsible for generating the HTML reports and living documentation.

This design of the framework made it easier for you to add it as a dependency to your project. However, it also meant that as the framework grew over time and became more sophisticated, you might have had to depend on parts of it that you did not necessarily need. For example, why should you need a dependency on Protractor and WebDriver if you were only writing API tests? And why would you need an adapter for Mocha if you were only ever going to use Cucumber? The answers to those questions seem obvious now, but when I originally designed the framework back in 2016, I never expected how popular it would become and how many use cases you'd find for it, dear fellow engineers! :-)

But, several years and thousands of Serenity/JS installations later, the internal structure of Serenity/JS 2.0 is in stark contrast with what you would've found here before.

While almost all the public APIs you'd use in your tests remained intact to help you make the upgrade as easy as possible, internally Serenity/JS 2.x has been re-architected to become a full-stack acceptance testing framework. Most importantly, it now offers a modular, pluggable architecture that you can extend to your needs to make your tests interact with any interface of your system, not just the Web and HTTP/REST APIs.

New dependencies

The main consequence of those internal design changes is that the Serenity/JS framework is now distributed as a collection of NPM modules under the @serenity-js/* namespace.

While this distribution model requires you to think a little bit more about what parts of the framework you'll actually need for your project, it also makes the overall design much easier for you to extend and the framework itself more lightweight. Check out the modules page to see what official Serenity/JS modules are currently available.

So, assuming that your Serenity/JS 1.x-based project interacted with the Web interface of your system (after all, that was the most common use case), the first thing you'll need to do is to add the following Serenity/JS 2.x modules to the dependencies or devDependencies section of your packages.json:

"@serenity-js/assertions": "^2.0.0",
"@serenity-js/console-reporter": "^2.0.0",
"@serenity-js/core": "^2.0.0",
"@serenity-js/protractor": "^2.0.0",
"@serenity-js/rest": "^2.0.0",
"@serenity-js/serenity-bdd": "^2.0.0"

Semantic versioning

Serenity/JS uses semantic versioning and we take semantic versioning, as well as backwards compatibility and deprecations very seriously. This means that the best way for you to stay up-to-date with all the latest features and patches is to set the version of @serenity-js/* modules you depend on to "^2.0.0".

However, if you'd prefer to stay on a fixed version instead, you can find out the latest available version by visiting the releases page.

Since test runner adaptors are no longer part of the core framework but instead live in their own independent modules, the next thing you'll need to do is to pick such adaptor for your test runner of choice.

For example, if you were using Serenity/JS with Cucumber, you'll need the Serenity/JS Cucumber adaptor:

"@serenity-js/cucumber": "^2.0.0",

If you were using mocha, you'll need to switch to jasmine for now and add the below dependencies:

"@serenity-js/jasmine": "^2.0.0",
"jasmine": "^3.5.0",

Both Mocha and Jasmine offer nearly identical describe and it APIs, and since Serenity/JS 2.0 offers its own assertions library, you're unlikely to see much difference between the two test runners.

Where's support for Mocha?

While Serenity/JS 2.0 was in development, Mocha was undergoing a number of API changes, which made it easier for Serenity/JS to integrate with a more stable Jasmine instead.

However, there's nothing preventing Serenity/JS from supporting Mocha, or Jest, or any other test runner in the future. So if that's something you'd like to see, please raise a ticket or consider sponsoring this feature.

Obsolete dependencies

There's a number of libraries that Serenity/JS used to depend on that you won't need anymore with version 2.0 of the framework.

In particular:

  • chai is no longer needed as it's been superseded by @serenity-js/assertions,
  • mocha test runner is not yet supported, but you can use jasmine test runner instead as their main APIs (describe and it) are nearly identical,
  • serenity-cli has been merged with other code that enables integration of Serenity/JS and Serenity BDD and is available as @serenity-js/serenity-bdd.

All the above means that you can remove the following entries from the dependencies or devDependencies section of your packages.json:

"@types/chai": "...",
"@types/chai-as-promised": "...",
"@types/mocha": "...",
"chai": "...",
"chai-as-promised": "...",
"chai-smoothie": "...",
"mocha": "...",
"serenity-cli": "...",
"serenity-js": "...",

Updated scripts

In Serenity/JS 2.0, the old serenity-cli module that used to provide a wrapper around Serenity BDD CLI, has been merged with other code integrating Serenity/JS with Serenity BDD and is now available as @serenity-js/serenity-bdd.

This new module ships with a new command you can use to download the Serenity BDD CLI jar. This means that any of the pretest or postinstall scripts defined in your package.json that used to call serenity update should be changed to call serenity-bdd update instead.

Before
"postinstall": "serenity update --ignoreSSL",
After
"postinstall": "serenity-bdd update --ignoreSSL", 

Learn more about @serenity-js/serenity-bdd.

Updated imports

In Serenity/JS 1.x, all the core and Protractor-specific APIs you'd use in your tests were made available via the serenity-js module. It was also common to import them either from serenity-js/lib/serenity-protractor, or its alias serenity-js/protractor, for example:

import { Task, Click, Enter, /** etc. **/ } from 'serenity-js/protractor';
// or
import { Task, Click, Enter, /** etc.  **/ } from 'serenity-js/lib/serenity-protractor';

Another approach some engineers chose to use in their custom Task and Interaction classes was to import the Serenity/JS Screenplay Pattern APIs directly from @serenity-js/core/lib/screenplay or its alias serenity-js/lib/screenplay, for example:

import { Task, Interaction, Actor, /** etc **/ } from '@serenity-js/core/lib/screenplay';
// or
import { Task, Interaction, Actor, /** etc **/ } from 'serenity-js/lib/screenplay';

Because of its modular architecture, the nature of Serenity/JS 2.x imports has changed as well.

If your source files import core Screenplay types such as Task, Actor, Interaction, Question or Ability from serenity-js/lib/screenplay-protractor, they should now do so from @serenity-js/core.

All the other Protractor-specific types, such as Click, Enter, etc. can be imported from the @serenity-js/protractor module:

Before
import { 
    Actor, Task, Interaction, Click, Enter, BrowseTheWeb, /** etc. **/ 
} from 'serenity-js/protractor';

// or
import {
    Actor, Task, Interaction, Click, Enter, BrowseTheWeb, /** etc.  **/
} from 'serenity-js/lib/serenity-protractor';
After
// core Screenplay APIs:
import { Task, Interaction, Actor, /** etc **/ } from '@serenity-js/core';

// Protractor-specific APIs:
import { Click, Enter, BrowseTheWeb, /** etc **/ } from '@serenity-js/protractor';

Find and replace

You'll most likely have to change many of the imports used in your code base by manually editing the code as per the above instructions. However, if you used one of the more specific imports, such as the ones in the before column in the table below, you might simplify the process by using the "replace all" / "replace in path" function in your IDE to quickly make those changes across all your source files.

before (1.x) after (2.x)
serenity-js/lib/serenity-protractor @serenity-js/protractor
serenity-js/lib/screenplay @serenity-js/core
@serenity-js/core/lib/screenplay @serenity-js/core

Project-level "find and replace"

To find out more about replacing multiple occurrences of a given string in your project, consult your IDE's manual.

For example, here's how you'd do it in WebStorm, IntelliJ, and Visual Studio Code.

Configuration

Serenity/JS 2.x is a full-stack acceptance testing framework and can be used with or without a web browser. However, since the most common use case for Serenity/JS 1.x was to run Web-based tests via Protractor, in this guide I'll focus on upgrading this particular setup.

To learn more about configuring a test runner that doesn't use Protractor, check out the documentation for the Serenity/JS module you're interested in, for example the Cucumber and Jasmine adaptors.

Protractor

Serenity/JS 1.x bundled the various reporting services, a.k.a. the StageCrewMembers, in the serenity-js module, which made it easier to import them in your protractor.conf.js file.

In Serenity/JS 2.x, the StageCrewMembers are bundled together with other code supporting given integration, i.e. @serenity-js/protractor or @serenity-js/serenity-bdd, or have their own standalone modules, i.e. @serenity-js/console-reporter.

The new StageCrewMembers also no longer rely on the file system, as they used to in Serenity/JS 1.x. Instead, they delegate the responsibility of storing the artifacts they generate (such as screenshots, test reports, and so on) on disk to the ArtifactArchiver.

The above changes mean that the imports in a typical protractor.conf.js file will need to change as follows:

Before
// protractor.conf.js

const {
  consoleReporter,
  serenityBDDReporter,
  photographer,
} = require('serenity-js/lib/stage_crew');
After
// protractor.conf.js

const 
  { ConsoleReporter } = require('@serenity-js/console-reporter'),
  { ArtifactArchiver } = require("@serenity-js/core"),
  { Photographer, TakePhotosOfInteractions } = require('@serenity-js/protractor'),
  { SerenityBDDReporter } = require("@serenity-js/serenity-bdd");

Once you have imported the new StageCrewMembers, you can tell the framework to use them using syntax similar to version 1.x, as per the code sample below.

The main difference, though, is that since the core framework no longer depends on Protractor, instead of configuring the frameworkPath parameter to point at require.resolve('serenity-js'), you'll now point it at require.resolve('@serenity-js/protractor/adapter'):

// protractor.conf.js

// Serenity/JS imports
const 
  { ConsoleReporter } = require('@serenity-js/console-reporter'),
  { ArtifactArchiver } = require("@serenity-js/core"),
  { Photographer, TakePhotosOfFailures } = require('@serenity-js/protractor'),
  { SerenityBDDReporter } = require("@serenity-js/serenity-bdd");

exports.config = {

    // Serenity/JS config
    framework:      'custom',
    frameworkPath:  require.resolve('@serenity-js/protractor/adapter'),

    serenity: {
        crew: [
            ArtifactArchiver.storingArtifactsAt("./target/site/serenity"),
            Photographer.whoWill(TakePhotosOfFailures),
            new SerenityBDDReporter(),
            ConsoleReporter.withDefaultColourSupport(),
        ],
    },

    // Test runner config [...]

    // Other Protractor config [...]
};

Learn more about the stage crew:

Migrating from Mocha to Jasmine

Serenity/JS 2.x does not support Mocha (yet!).

If you were using Mocha with your Serenity/JS 1.x test suite, you can easily switch to Jasmine since there are very few differences between the two runners, given that the responsibility for performing the assertions is now with @serenity-js/assertions rather than with the test runner itself.

Provided you have added a recent version of jasmine to you package.json, you can configure Protractor to use it as follows:

Before
// protractor.conf.js

// Serenity/JS imports [...]

exports.config = {

    // Serenity/JS config [...] 

    // Test runner config
    specs: [ 'spec/*.spec.ts', ],

    mochaOpts: {
        ui: 'bdd',
        compiler: 'ts:ts-node/register',
    },

    // Other Protractor config [...]
}
After
// protractor.conf.js

// Serenity/JS imports [...]

exports.config = {

    // Serenity/JS config [...] 

    // Test runner config
    specs: [ 'spec/*.spec.ts', ],

    jasmineNodeOpts: {
        requires: [ 'ts-node/register' ],
        helpers: [ 'spec/setup.ts' ] 
    },

    // Other Protractor config [...]
};

Please note that the above config instructs Jasmine to load a setup.ts file located at spec/setup.ts. While this is not mandatory, you can use a setup file like that to further configure Jasmine and Serenity/JS to your needs. I'll explain it in more depth in the next section.

I'd still rather use Mocha (or Jest, or Ava, or Karma...)

Sure thing, and we'd love to support it! However, with limited time and virtually unlimited possibilities for extending Serenity/JS, we have to be very strict about our priorities.

If you'd like to see Serenity/JS support Mocha, or any other test runner for this matter, please raise a ticket or give a thumbs up to an existing proposal.

Also, please consider becoming our Github Sponsor to help the Serenity/JS team secure more time on the project to support more integrations.

Cucumber

While Serenity/JS supported Cucumber.js version 1.3.3, Serenity/JS 2.x supports all the versions of Cucumber.js from 0.x to 6.x.

Provided you have added cucumber to you package.json, you can configure Protractor to use it as follows:

// protractor.conf.js

// Serenity/JS imports [...]

exports.config = {

    // Serenity/JS config [...] 

    // Test runner config
    specs: [ 'features/*.feature', ],

    cucumberOpts: {
        require: [
            'features/step_definitions/**/*.ts',
            'features/support/setup.ts',
        ],
        'require-module': ['ts-node/register'],
        tags: [],
    },

    // Other Protractor config [...]
};

Please note that the above config instructs Cucumber.js to load a setup.ts file located at features/support/setup.ts. While this is not mandatory, you can use a setup file like that to further configure Cucumber.js and Serenity/JS to your needs. I'll explain it in more depth in the next section.

Cucumber without Protractor

If you'd like to use Serenity/JS with Cucumber but without Protractor, i.e. for non-Web testing, have a look at the documentation of the @serenity-js/cucumber module.

Actors and the Stage

Now that you know how to configure the Serenity/JS 2.x framework, it's time to look at how to upgrade your existing tests to take advantage of the new features.

One of the important changes that Serenity/JS 2.x brings to the table is in how it simplifies the way you create, manage and access the Actors, the cornerstone of the Screenplay Pattern.

In Serenity/JS 1.x you had to instantiate the Stage where whe Actors would perform, and make sure that it's accessible in your tests.

For example, if you were using Cucumber, you'd first define a Cast of Actors:

// features/support/Actors.ts

import { protractor } from 'protractor';
import { Actor, BrowseTheWeb, Cast } from 'serenity-js/lib/screenplay-protractor';

export class Actors implements Cast {
    actor(name: string): Actor {
        return Actor.named(name)
            .whoCan(BrowseTheWeb.using(protractor.browser));
    }
}

You'd then use a mechanism like Cucumber World to tell Serenity/JS to instantiate the Stage and make it available in your test steps:

// features/support/world.ts

import { serenity } from 'serenity-js/lib/screenplay-protractor';
import { Actors } from './Actors.ts';

export = function () {

    this.World = function () {
        this.stage = serenity.callToStageFor(new Actors());
    };
};

Next, you'd access the Actor by invoking the theActorCalled(name) and theActorInTheSpotlight() APIs provided by the Stage:

// features/step_definitions/steps.ts
export = function () {

    this.Given(/^.*that (.*) has an empty todo list$/, function (actorName: string) {
        return this.stage.theActorCalled(actorName).attemptsTo(
            // some tasks to perform...
        );
    });

    this.When(/^she adds "(.*?)" to her todo list$/, function (itemName: string) {
        return this.stage.theActorInTheSpotlight().attemptsTo(
            // some tasks to perform...
        );
    });
};

While the same general principle applies in version 2.x as well, the way you interact with the framework has been simplified, so let me walk you through it step by step.

Changes to the Cast

Both Serenity/JS 1.x and 2.x provide a Cast interface that needs to be implemented by the class responsible for providing the Actors for your tests.

However, while Serenity/JS 1.x expected the Cast to instantiate the Actors, version 2.x instantiates them for you.

All you need to do is to prepare the actors for the performance by giving them the Abilities they need:

Before
// features/support/Actors.ts

import { protractor } from 'protractor';
import { Actor, BrowseTheWeb, Cast } from 'serenity-js/lib/screenplay-protractor';

export class Actors implements Cast {
    actor(name: string): Actor {
        return Actor.named(name)
            .whoCan(BrowseTheWeb.using(protractor.browser));
    }
}
After
// features/support/Actors.ts

import { Actor, Cast } from '@serenity-js/core';
import { BrowseTheWeb } from '@serenity-js/protractor';
import { protractor } from 'protractor';

export class Actors implements Cast {
    prepare(actor: Actor): Actor {              // `prepare(actor: Actor)` instead of `actor(name: string)`
        return actor.whoCan(                    // add the abilities instead of instantiating the actor
            BrowseTheWeb.using(protractor.browser),
        );
    }
}

You don't need the World

The second important difference is in how you tell Serenity/JS what actors to use.

In Serenity/JS 1.x you had to rely on Cucumber World to set up the Stage and assign it to Cucumber context (this).

In version 2.x, Serenity/JS takes on the responsibility of managing the execution context, so all you need to do is simply tell the framework what actors you want to engage before each scenario.

Before (Cucumber 1.3.3, Serenity/JS 1.x)
// features/support/world.ts

import { serenity } from 'serenity-js/lib/screenplay-protractor';
import { Actors } from './Actors.ts';

export = function () {

    this.World = function () {
        this.stage = serenity.callToStageFor(new Actors());
    };
};
After (Cucumber 1.3.3, Serenity/JS 2.x)
// features/support/setup.ts

import { engage } from '@serenity-js/core';
import { Actors } from './Actors';

export = function () {
    this.Before(() => engage(new Actors());
}
After (Cucumber 6.x, Serenity/JS 2.x)

If you decided to upgrade Cucumber to recent version at the same time you upgrade Serenity/JS, you could simplify the above setup code even further:

// features/support/setup.ts

import { engage } from '@serenity-js/core';
import { Before } from 'cucumber';
import { Actors } from './Actors';

Before(() => engage(new Actors());

Accessing the actors

When using Cucumber without Serenity/JS, you use Cucumber this to manage state across multiple step definitions. While this mechanism is quite convenient, it also requires you to use the full-blown function syntax since the simpler arrow functions don't have a separate this. For this reason, Cucumber documentation encourages you to avoid the arrow functions altogether.

In version 2.0, Serenity/JS takes on the responsibility of managing state in your test scenarios, makes accessing the actors easier via actorCalled and actorInTheSpotlight, and since it doesn't rely on Cucumber this - it allows you to use the convenient and compact arrow functions to help you simplify your code further.

Before (Cucumber 1.3.3, Serenity/JS 1.x)
// features/step_definitions/steps.ts

export = function () {

    this.Given(/^.*that (.*) has an empty todo list$/, function (actorName: string) {
        return this.stage.theActorCalled(actorName).attemptsTo(
            // some tasks to perform...
        );
    });

    this.When(/^she adds "(.*?)" to her todo list$/, function (itemName: string) {
        return this.stage.theActorInTheSpotlight().attemptsTo(
            // some tasks to perform...
        );
    });
};
After (Cucumber 1.3.3, Serenity/JS 2.x)
// features/step_definitions/steps.ts

import { actorCalled, actorInTheSpotlight } from '@serenity-js/core';

export = function () {

    this.Given(/^.*that (.*) has an empty todo list$/, (actorName: string) => {
        return actorCalled(actorName).attemptsTo(
            // some tasks to perform...
        );
    });

    this.When(/^she adds "(.*?)" to her todo list$/, (itemName: string) => {
        return actorInTheSpotlight().attemptsTo(
            // some tasks to perform...
        );
    });
};
After (Cucumber 6.x, Serenity/JS 2.x)
// features/step_definitions/steps.ts

import { actorCalled, actorInTheSpotlight } from '@serenity-js/core';
import { Given, When } from 'cucumber';

Given(/^.*that (.*) has an empty todo list$/, (actorName: string) => {
    return actorCalled(actorName).attemptsTo(
        // some tasks to perform...
    );
});

When(/^she adds "(.*?)" to her todo list$/, (itemName: string) => {
    return actorInTheSpotlight().attemptsTo(
        // some tasks to perform...
    );
});
After (Cucumber 6.x, Serenity/JS 2.x, concise body)

If you want to take refactoring your step definitions even further, you could use arrow functions with a "concise body" and drop the return statements altogether:

// features/step_definitions/steps.ts

import { actorCalled, actorInTheSpotlight } from '@serenity-js/core';
import { Given, When } from 'cucumber';

Given(/^.*that (.*) has an empty todo list$/, (actorName: string) => 
    actorCalled(actorName).attemptsTo(
        // some tasks to perform...
    ));

When(/^she adds "(.*?)" to her todo list$/, (itemName: string) =>
    actorInTheSpotlight().attemptsTo(
        // some tasks to perform...
    ));

Actors in Jasmine tests

The above-described mechanism for accessing the actors works regardless of the test runner you're using.

For example, this is how you could use the same strategy to implement a Jasmine setup.ts file:

// spec/setup.ts

import 'jasmine';

import { Actor, Cast, engage } from '@serenity-js/core';
import { BrowseTheWeb } from '@serenity-js/protractor';
import { protractor } from 'protractor';

class Actors implements Cast {
    prepare(actor: Actor): Actor {              // `prepare(actor: Actor)` instead of `actor(name: string)`
        return actor.whoCan(                    // add the abilities instead of instantiating the actor
            BrowseTheWeb.using(protractor.browser),
        );
    }
}

beforeEach(() => engage(new Actors()));         // the `beforeEach` can be defined either in spec/setup.ts
                                                // or in each spec file. 

And a test using the Jasmine test runner:

// spec/example.spec.ts

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

describe('some feature', () => {

    it('has some behaviour', () => 
        actorCalled('Jannice').attemptsTo(
            // some tasks to perform...
        ));   
});

Implementing the Screenplay Pattern

Tasks

The best way to illustrate how the Task has evolved is by using a concrete example.

Consider a task to AddAnItem to an imaginary to-do list that we'd invoke as follows:

actorCalled('Tasha').attemptsTo(
    AddAnItem.called('Learn Serenity/JS'),
);

In Serenity/JS 1.x, the task to AddAnItem could be implemented like this:

import { Enter, step, PerformsTasks, Task } from 'serenity-js/protractor';

/**
 * Any custom task had to implement the `Task` interface
 */
export class AddAnItem implements Task {

    static called(name: string) {
        return new AddAnItem(name);
    }

    constructor(private readonly name: string) {
    }

    /**
     * The `performAs` method received an `actor` who `PerformsTasks`
     * and returned a `PromiseLike<void>`
     *
     * The `@step` decorator was responsible for generating 
     * a human-friendly description to be used in the report
     */
    @step('{0} adds an item called #name')
    performAs(actor: PerformsTasks): PromiseLike<void> {
        return actor.attemptsTo(
            // The `attemptsTo` method invoked lower-level
            // tasks and interactions, such as `Click`, `Enter`, etc.
            Enter.theValue(this.name).into(/* some element */),
            // ...
        );
    }    
}

The same task implemented using the 2.x version would look as follows:

import { Task, PerformsActivities } from '@serenity-js/core';
import { Enter } from '@serenity-js/protractor';

export class AddAnItem extends Task {
    static called(name: string) {
        return new AddAnItem(name);
    }    

    constructor(private readonly name: string) {
        super();
    }

    performAs(actor: PerformsActivities): PromiseLike<void> {
        return actor.attemptsTo(
            // ... list lower-level tasks and interactions:
            Enter.theValue(this.name).into(/* some element */),
        )   
    }

    toString() {
        return `#actor adds an item called ${ this.name }`;
    }
}

As you might have noticed, the above two code samples have several subtle differences:

  • the imports are now more explicit, with all the Screenplay-specific types coming from @serenity-js/core and Protractor-specific ones from @serenity-js/protractor,
  • instead of implementing a Task interface, the custom task now extends a base Task class; extending a base class helps Serenity/JS distinguish the different types of activities performed by the actor at runtime and, for example, to capture screenshots upon Interactions, but not upon Tasks,
  • the @step decorator is now superseded by a much more obvious toString method.

Please note that you also have an opportunity here to take your refactoring further as Serenity/JS 2.x still provides you with a convenient Task.where factory method that can generate the task from the above example with much less code:

import { Task } from '@serenity-js/core';
import { Enter } from '@serenity-js/protractor';

exports const AddAnItem = {
    called: (name: string) =>
        Task.where(`#actor adds an item called ${ this.name }`,
            // ... list lower-level tasks and interactions:
            Enter.theValue(this.name).into(/* some element */),
        ),       
}

Interactions

Interaction have undergone an overhaul similar to tasks.

Consider an example Serenity/JS 1.x interaction that enables a disabled input element by injecting some JavaScript into the browser using Protractor's executeScript API, available via the ability to BrowseTheWeb:

import { BrowseTheWeb, Interaction, step, Target, UsesAbilities } from 'serenity-js/protractor';

export class Enable implements Interaction {

    public static the(target: Target): Enable {
        return new Enable(target);
    }

    constructor(private readonly target: Target) {
    }

    @step('{0} enables #target')
    performAs(actor: UsesAbilities): PromiseLike<void> {
        return BrowseTheWeb.as(actor).
            executeScript(`arguments[0].removeAttribute("disabled");`, this.target);
    }
}

Now, compare it with this 2.x implementation:

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

export class Enable extends Interaction {
    static the(target: Question<ElementFinder>) {
        return new Enable(target);
    }

    constructor(private readonly target: Question<ElementFinder>) {
        super();
    }

    performAs(actor: UsesAbilities & AnswersQuestions): PromiseLike<void> {
        return this.target.answeredBy(actor).then(element => {
            return BrowseTheWeb.as(actor)
                .executeScript(`arguments[0].removeAttribute("disabled");`, element);
        });
    }

    toString(): string {
        return `#actor enables ${this.target}`;
    }
}

In Serenity/JS 2.x, a custom interaction:

  • extends the base Interaction class,
  • is responsible for resolving any Questions (more on this and the difference between Target and Question later)
  • uses toString method rather than a @step decorator

Similarly to the Task, the Interaction class provides a convenient factory method Interaction.where to make defining custom interactions easier. For instance, the above code sample could be implemented as follows:

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

export const Enable = {
    the: (target: Question<ElementFinder>) =>
        Interaction.where(`#actor enables ${ target }`, (actor: UsesAbilities & AnswersQuestions) => {
            return target.answeredBy(actor)
                .then(element => {
                    return BrowseTheWeb.as(actor)
                        .executeScript(`arguments[0].removeAttribute("disabled");`, element);
                });
        }),
    },
}

PRO TIP: If you ever have the need to inject JavaScript into the UI of your system under test, please use the built-in ExecuteScript interaction instead:

import { Question, Task } from '@serenity-js/core';
import { ExecuteScript } from '@serenity-js/protractor';

export const Enable = {
    the: (target: Question<ElementFinder>) =>
        Task.where(`#actor enables ${ target }`,
            ExecuteScript.sync(`arguments[0].removeAttribute("disabled");`).
                withArguments(target), 
        ),
}

Questions

Apart from the main Screenplay Pattern interfaces, one of the most important and most commonly used classes in Serenity/JS 1.x was the Target.

The 1.x Target was a special class responsible for abstracting the way Protractor locates WebElements, or ElementFinders and ElementArrayFinders in Protractor-speak.

However, unlike all the other classes responsible for retrieving information about the system under test, the Target was not considered a Question, as it could only be used with Protractor-specific interactions.

In Serenity/JS 2.x, the information retrieval mechanism is consistent across the board. This means that no matter whether you're retrieving a JSON body of the last REST response, a text of a web element, or the web element itself, they all implement the Question interface.

This means that Serenity/JS 2.x Targets can be injected into Protractor-specific interactions, but also assertions and test synchronisation methods. You can also nest Targets, therefore limiting the need for the problematic XPath selectors in your code base.

As you have probably already guessed, introducing this level of sophistication and flexibility required changing how the Target class works.

The original Target is now modelled by four specialised Question classes and the Target class itself was turned into a factory that takes care of instantiating them correctly.

However, the way you use it has remained intact:

import { Target } from '@serenity-js/protractor';
import { by } from 'protractor';

// simple lean page object
class ToDoListApp {

    // Target.the captures a single element
    // and produces Question<ElementFinder> 
    static header = Target.the('header').located(by.css('h1'));

    // Target.all captures multiple elements
    // and produces Question<ElementArrayFinder>
    static items  = Target.all('the items').located(by.css('#todolist li'));
}

To learn more, check out the examples in the API docs of the Target class.

Targets as arguments

Since the responsibilities of the 2.x Target differ from its predecessor, if you have written any custom Activity classes in your project where a Target is passed as an argument (for example in a constructor or a method call), you'll need to change the signatures to receive a Question<ElementFinder> for single-element activities or Question<ElementArrayFinder> for multi-element activities.

Consider a hypothetical Serenity/JS 1.x interaction we discussed earlier, the one that enabled a disabled element:

import { BrowseTheWeb, Interaction, step, Target, UsesAbilities } from 'serenity-js/protractor';

export class Enable implements Interaction {

    public static the(target: Target): Enable {
        return new Enable(target);
    }

    constructor(private readonly target: Target) {
    }

    @step('{0} enables #target')
    performAs(actor: UsesAbilities): PromiseLike<void> {
        return BrowseTheWeb.as(actor).
            executeScript(`arguments[0].removeAttribute("disabled");`, this.target);
    }
}

Since the above interaction accepts a single-element Target, it will need to change to accept Question<ElementFinder> instead.

Additionally, its performAs method will now need to resolve (a.k.a. "answer") the question before passing the underlying ElementFinder to lower-level abilities and Protractor-specific method calls:

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

export class Enable extends Interaction {

    // Target -> Question<ElementFinder>
    static the(target: Question<ElementFinder>) {
        return new Enable(target);
    }

    // Target -> Question<ElementFinder>
    constructor(private readonly target: Question<ElementFinder>) {
        super();
    }

    performAs(actor: UsesAbilities & AnswersQuestions): PromiseLike<void> {
        // the actor needs to answer the question
        // so that it can pass a Protractor-specific
        // ElementFinder object to BrowseTheWeb
        return this.target.answeredBy(actor).then((element: ElementFinder) => {
            return BrowseTheWeb.as(actor)
                .executeScript(`arguments[0].removeAttribute("disabled");`, element);
        });
    }

    toString(): string {
        return `#actor enables ${this.target}`;
    }
}

Similarly, if your custom Activity used to receive a product of the Target.all(...) call, so a multi-element Target, it will now need to change to receive Question<ElementArrayFinder>.

Assertions

Serenity/JS 1.x did not have its own assertions library. Instead, it provided an interaction to See.if which you'd use to delegate the act of performing the actual assertion to a library like chai.js, typically combined with plugins like chai-as-promised and chai-smoothie.

For example:

import { See, Target, Text } from 'serenity-js/lib/serenity-protractor';
import { by } from 'protractor';

// import chai and chai-as-promised to assert on promises
import chai = require('chai');
import chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);

const TodoListItems = Target.the('items on the list').located(by.css('ul.todo-list li'));

actor.attemptsTo(
    See.if(Text.ofAll(TodoListItems), (textOfItems: PromiseLike<string[]>) =>
        // delegate the assertion to chai
        chai.expect(textOfItems).to.eventually.contain('Buy some milk')
    ),
)  

If you're happy with this model, Serenity/JS 2.x still ships with an interaction to See.if, which you can import from the @serenity-js/core module and your old assertions will work exactly the same way they used to.

However, 2.x gives you a brand new @serenity-js/assertions library that enables you to implement the above code sample as follows:

import { contain, Ensure } from '@serenity-js/assertions';
import { Target, Text } from '@serenity-js/protractor';
import { by } from 'protractor';

const TodoListItems = Target.the('items on the list').located(by.css('ul.todo-list li'));

actor.attemptsTo(
    Ensure.that(Text.ofAll(TodoListItems), contain('Buy some milk')),
)  

In the above example, the expectation for a list to contain an item is one of the many expectations that ship with the @serenity-js/assertions module.

There's a number of great things about this new design, that I'm particularly proud of.

For example, expectations can be composed:

import { contain, Ensure, not } from '@serenity-js/assertions';

actor.attemptsTo(
    Ensure.that(Text.ofAll(TodoListItems), not(contain('Buy some milk'))),
)  

Which is useful if you need to cater for the much more sophisticated use cases:

import { 
    and,
    contain,
    containAtLeastOneItemThat,
    Ensure,
    equals,
    not
} from '@serenity-js/assertions';

actor.attemptsTo(
    Ensure.that(Text.ofAll(TodoListItems), and(
        containAtLeastOneItemThat(equals('Buy some milk')),
        not(contain('Buy chocolate'))
    )),
);

Another great thing about the new expectations is that they're now compatible with other interactions, not just the one to Ensure!

For example, you can use them to synchronise your tests with the UI thanks to the interaction to Wait:

import { Text, Wait } from '@serenity-js/protractor';
import { includes } from '@serenity-js/assertions';

actor.attemptsTo(
    Wait.until(Text.of(StatusBar), includes('Finished loading!'))
);

You can also use them to control the flow of your test scenario using the interaction to Check:

import { Click, isVisible } from '@serenity-js/protractor';
import { Check } from '@serenity-js/assertions';

actor.attemptsTo(
    Check.whether(CookieConsent.Dialog, isVisible())
        .andIfSo(Click.on(CookieConsent.AcceptButton))
);

Did you notice that the @serenity-js/protractor module ships with UI-specific expectations, such as isVisible? They are as powerful as all the other expectations I showed you so far, which means that you can use them with Wait, Check and Ensure.

Those new UI-specific expectations replace the ones that used to ship with Serenity/JS 1.x:

before after
Is.clickable() isClickable()
Is.enabled() isEnabled()
Is.present() isPresent()
Is.selected() isSelected()
Is.visible() isVisible()

Before you go

Hopefully this guide gave you a good understanding of how version 2.0 improves upon the original design and can help you to write even better acceptance tests.

If Serenity/JS has made your life easier, please give us a star ★ on Github and consider getting us a coffee every now and then by becoming a Github Sponsor of the project from as little as $5.

Need a hand?

If you'd like some help with the upgrade or a member of the Serenity/JS core team to review your code base - get in touch.