Skip to main content

Upgrading to Serenity/JS 3

Serenity/JS 3.0 introduces a number of new features while aiming to retain backwards compatibility of most of the core APIs.

Serenity/JS 3 is available on NPM and this guide will help you get started and highlight notable differences from Serenity/JS 2.

All the Serenity/JS Project Templates are migrated to Serenity/JS 3. You'll find code using the new Serenity/JS APIs on the main branch while the 2.x branch still contains Serenity/JS 2 code for comparison:

Universal Web Testing Façade and Portable Test Code

The most significant change in the Web testing space is the introduction of the universal web testing façade - @serenity-js/web, and numerous features that help your tests become portable across the different test integration tools, such as Protractor, WebdriverIO, Playwright, Puppeteer, and so on.

The new module contains all the Web-related interactions and questions, while the integration-tool specific modules such as @serenity-js/playwright, @serenity-js/protractor and @serenity-js/webdriverio contain only tool-specific models and abilities. This change will help your test code be much more portable between the different integration tools, and will also help us significantly reduce the effort of introducing new integrations.

To see what the changes look like in practice, have a look at the TodoMVC tests implemented using:

Let's discuss the changes below.

Configuring the Actors

The only non-portable part of Serenity/JS 3.x Web tests is your Actors class. That's because while your tests can be agnostic of the lower-level integration tool, the Actors need to "know" what tool to use.

The first change you'll see is in how the Actors class is defined.

In Serenity/JS 2:

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

export class Actors implements Cast {
prepare(actor: Actor): Actor {
return actor.whoCan(
BrowseTheWeb.using(browser),
);
}
}

In Serenity/JS 3:

import { Actor, Cast } from '@serenity-js/core';
import { BrowseTheWebWithWebdriverIO } from '@serenity-js/webdriverio';

export class Actors implements Cast {
prepare(actor: Actor): Actor {
return actor.whoCan(
BrowseTheWebWithWebdriverIO.using(browser),
);
}
}

So here's the difference:

  • Instead of importing BrowseTheWeb you import BrowseTheWebWithWebdriverIO (which is a tool-specific implementation of the BrowseTheWeb interface)
  • Next, you give the new tool-specific ability to the actor:
import { Actor, Cast } from '@serenity-js/core';
- import { BrowseTheWeb } from '@serenity-js/webdriverio';
+ import { BrowseTheWebWithWebdriverIO } from '@serenity-js/webdriverio';

export class Actors implements Cast {
prepare(actor: Actor): Actor {
return actor.whoCan(
- BrowseTheWeb.using(browser),
+ BrowseTheWebWithWebdriverIO.using(browser),
);
}
}

However, you might not need to a custom cast of actors at all! Serenity/JS now provides you a default one.

Default cast of actors

To make it easier for you to get started with Serenity/JS Screenplay Pattern APIs, several of the Serenity/JS test runner integrations now come with a default cast of actors:

You might not need a custom cast of actors

Default cast of actors provided by Serenity/JS v3 can help you to avoid having to specify a custom cast altogether.

The default Cast provides actors where each actor has the abilities to:

However, if the default configuration is not sufficient to your needs, every test runner integration still lets you configure a custom cast.

Learn how to configure a custom cast of actors with:

Implementing portable Interactions and Questions

Because BrowseTheWebWithWebdriverIO extends BrowseTheWeb, any custom interactions and questions should still use the generic and tool-agnostic BrowseTheWeb from @serenity-js/web to be portable between the different integration tools. Note that this also means that there's a good chance that any custom interactions and questions you have implemented with Serenity/JS 2 would still work with few if any changes with Serenity/JS 3.

How does it work? In Serenity 3, calling BrowseTheWeb.as(actor) looks up any ability that extends the base BrowseTheWeb, so currently either BrowseTheWebWithWebdriverIO, BrowseTheWebWithProtractor, or your custom extensions of those classes.

For example, the below custom interaction to ReloadPage is portable, which means that it works with both BrowseTheWebWithWebdriverIO and BrowseTheWebWithProtractor:

import { Actor, Interaction } from '@serenity-js/core';
import { BrowseTheWeb } from '@serenity-js/web'

const ReloadPage = () =>
Interaction.where(`#actor reloads a page`, (actor: Actor) => {
return BrowseTheWeb.as(actor).reloadPage();
});
Pro tip

If you're into software design patterns, you can think of Actors as tiny Dependency Injection Containers.

Portable PageElements

The next significant change is the removal of Target classes in favour of portable PageElement and PageElements implementations. Serenity/JS 3 also uses its own tool-agnostic locators.

It will all become more clear with an example.

In Serenity/JS 2 you'd define the elements you want your tests to interact with using syntax similar to the below:

import { equals } from '@serenity-js/assertions';
import { Question } from '@serenity-js/core';
import { by, Target } from '@serenity-js/webdriverio';

export class TodoList {
static newTodoInput =
Target.the('"What needs to be done?" input box')
.located(by.css('.new-todo'));

static editTodoInput =
Target.the('"What needs to be done?" input box')
.located(by.css('.todo-list li.editing .edit'));

static items =
Target.all('List of Items')
.located(by.css('.todo-list li'));

static itemCalled = (name: string): Question<Promise<Element<'async'>>> =>
TodoList.items
.where(Text, equals(name))
.first();
}

Note that in the listing above:

  • by is tool-specific and comes from @serenity-js/webdriverio
  • TodoList.itemCalled is defined as returning Question<Promise<Element<'async'>>> with Element again being tool-specific and coming from webdriverio

Both of the above issues make our code bound to the lower-level test integration tool.

In Serenity/JS 3 the changes to implementation look relatively small, but have powerful consequences:

import { By, PageElement, PageElements } from '@serenity-js/web';
import { includes } from '@serenity-js/assertions';

export class TodoList {
static newTodoInput =
PageElement.located(By.css('.new-todo'))
.describedAs('"What needs to be done?" input box')
;

static editTodoInput =
PageElement.located(By.css('.todo-list li.editing .edit')).describedAs('edit field');

static items =
PageElements.located(By.css('.todo-list li')).describedAs('list of items');

static itemCalled = (name: string) =>
TodoList.items
.where(Text, includes(name))
.first()
.describedAs(`item called '${ name }'`);
}

To see the new PageElement and PageElements APIs in action, including using advanced element filters and mapping, check out the section on Page Element Query Language and have a look at the PageElements patterns spec on GitHub.

Want more docs?

If you'd like to see more tutorials or a screencast on this topic, let me know in the comments section below 👇👇👇

Taking Notes

The ability to TakeNotes, the question about Note, and the interaction to TakeNote have been completely re-written to provide better type safety, more flexibility, and to take advantage of the new QuestionAdapter APIs.

At the high level, there's a new class that represents the Notepad. You can type it to specify what sort of data you're planning to store in it:


import { TakeNotes, Notepad } from '@serenity-js/core';

// example interface describing the notes stored in the Notepad
interface MyNotes {
credentials: {
username?: string;
password?: string;
}
}

actorCalled('Leonard')
.whoCan(
TakeNotes.using(Notepad.empty<MyNotes>())
)

You can then record and retrieve notes using Notepad.notes<T>(), or a convenient alias - notes<T>(). Those new APIs replace the Note you might remember from Serenity/JS v2:

import { Log, Notepad, notes, TakeNotes } from '@serenity-js/core';

actorCalled('Leonard')
.whoCan(
TakeNotes.using(Notepad.empty<MyNotes>())
)
.attemptsTo(
notes<MyNotes>().set('credentials', { username: 'leonard@example.org', password: 'P@ssw0rd!' }),
Log.the(
notes<MyNotes>().get('credentials').username // note that `username` is a QuestionAdapter<string>
),
)

While you can still initialise the ability to TakeNotes.usingAnEmptyNotepad() (which is an alias for TakeNotes.using(Notepad.empty())), you can now also provide an initial state:

import { Note, Notepad, TakeNotes } from '@serenity-js/core';

actorCalled('Leonard')
.whoCan(
TakeNotes.using(
Notepad.with<MyNotes>({
credentials: {
username: 'leonard@example.org',
password: 'SuperSecretP@ssword1',
}
})
)
)

The factory method TakeNotes.usingASharedNotepad() has been removed, so if you'd like the actors to share notes, you'll need to give them the same instance of the Notepad to work with:

 import { Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core';

interface AuthCredentials {
username: string;
password: string;
}

interface MyNotes {
credentials: AuthCredentials;
}

export class Actors implements Cast {

// initialise a shared notepad when the Actors class is initialised
private readonly sharedNotepad = Notepad.with<MyNotes>({
credentials: {
username: 'test-user',
password: 'SuperSecretP@ssword!',
}
});

prepare(actor: Actor): Actor {
switch (actor.name) {
case 'Alice':
case 'Bob':
// Alice and Bob should share notes
return actor.whoCan(TakeNotes.using(this.sharedNotepad));
default:
// other actors should have their own notepads
return actor.whoCan(TakeNotes.using(Notepad.empty<MyNotes>()));
}
}
}

Another improvement is that notes<T>().get(noteName) now returns a QuestionAdapter. The adapter creates a Screenplay Pattern-style proxy around the underlying value, so when you invoke its methods the adapter generates Interactions and Questions as needed:

import { Log, Notepad, notes, TakeNotes } from '@serenity-js/core';

actorCalled('Leonard')
.whoCan(
TakeNotes.using(Notepad.empty<MyNotes>())
)
.attemptsTo(
notes<MyNotes>().set('credentials', {
username: 'leonard@example.org',
password: 'SuperSecretP@ssword1',
}),
Log.the(
notes<MyNotes>().get('credentials') // returns QuestionAdapter<AuthCredentials>
.username // returns QuestionAdapter<string>
.toLocaleUpperCase() // proxies toLocaleUpperCase and generates an Interaction around it
.charAt(0) // proxies charAt and generates a proxy, etc.
), // emits "L"
)

Using an untyped Notepad

If you don't want to use the typed notepad in the first steps of your migration, you can still use an untyped version:

 import { Actor, Cast, Notepad, TakeNotes } from '@serenity-js/core';

export class Actors implements Cast {

// initialise an empty shared notepad when the Actors class is initialised
private readonly sharedNotepad = Notepad.empty();

prepare(actor: Actor): Actor {
switch (actor.name) {
case 'Alice':
case 'Bob':
// Alice and Bob should share notes
return actor.whoCan(TakeNotes.using(this.sharedNotepad));
default:
// other actors should have their own notepads
return actor.whoCan(TakeNotes.using(Notepad.empty()));
}
}
}

You can then record and retrieve notes using your subject of choice, defined using a string:

import { Log, Note } from '@serenity-js/core';

actorCalled('Alice')
.attemptsTo(
notes().set('shopping list item', 'milk'),
Log.the(
notes().get('shopping list item')
),
)

The untyped flavour gives you access to QuestionAdapters just like the typed version, however your text editor might not be able to provide you with as much support as it would if your notepad had been typed.

Waiting

In Serenity/JS 2, interactions to Wait.for and Wait.until relied on browser-specific wait APIs, such as Protractor wait or WebdriverIO waitUntil. Since the interactions were specific to browser integration tools, they'd also come as part of @serenity-js/protractor or @serenity-js/webdriverio modules.

In Serenity/JS 3, interactions to Wait don't rely on any browser integration tool and are, in fact, completely browser-independent. What this means in practice is that you can use Wait for both browser and API tests.

Since Wait is no longer tied to the browser, it's also been moved to @serenity-js/core, along with Expectation:

import { actorCalled, Duration } from '@serenity-js/core';
- import { Wait } from '@serenity-js/protractor';
- import { Wait } from '@serenity-js/webdriverio';
+ import { Wait } from '@serenity-js/core';

actorCalled('Alice').attemptsTo(
Wait.for(Duration.ofSeconds(1)),

Wait.until(someQuestion, someExpectation)
.pollingEvery(Duration.ofMilliseconds(10)),

Wait.upTo(Duration.ofSeconds(5)
.until(someQuestion, someExpectation)
.pollingEvery(Duration.ofMilliseconds(10)),
)

Additionally, Wait.until has also received a new API allowing you to configure its polling interval (500ms by default):

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

actorCalled('Alice').attemptsTo(
Wait.until(someQuestion, someExpectation)
.pollingEvery(Duration.ofMilliseconds(10)),
)

@serenity-js/rest

Answerable<WithAnswerableProperties<AxiosRequestConfig>> in HTTP requests

All HTTP requests now accept Answerable<WithAnswerableProperties<AxiosRequestConfig>>, which means you can now specify additional HTTP request configuration using a configuration object with nested Questions, QuestionAdapters and Promises.

For example:

import { actorCalled, Question, q } from '@serenity-js/core';
import { Send, PostRequest } from '@serenity-js/rest';

await actorCalled('René').attemptsTo(
Send.a(
PostRequest.to('/products/2')
.with({ name: 'apple' })
.using({
headers: {
Authorization: q`Bearer ${ Question.about('token', actor => 'some-token') }`,
},
})
)
);

/*
sends a request with:

headers: {
Authorization: 'Bearer some-token',
},
*/
Pro tip

The code sample above uses q a tagged template function converting a string template parameterised with Answerable<string | number> into a QuestionAdapter<string>. Useful when you need to quickly concatenate strings and Question<string>.

Removed deprecated ChangeApiUrl

Deprecated interaction to ChangeApiUrl is now replaced by ChangeApiConfig.setUrlTo(newBaseUrl)

actor.attemptsTo(
- ChangeApiUrl.to(newBaseUrl),
+ ChangeApiConfig.setUrlTo(newBaseUrl),
);

@serenity-js/core

Screenplay-style dictionaries with Question.fromObject

A new Screenplay-style data structure, Answerable<WithAnswerableProperties<Source_Type>> will help you convert and merge plain JavaScript objects with nested Answerables into a QuestionAdapter<T>.

For example:


import { actorCalled } from '@serenity-js/core';
import { Send, PostRequest } from '@serenity-js/rest';

interface AddProductRequestData {
name: string;
quantity: number;
}

actorCalled('René').attemptsTo(
Send.a(
PostRequest.to('/products')
.with(
Question.fromObject<AddProductRequestData>({
name: Text.of(someElement),
quantity: Text.of(someOtherElement).as(Number)
})
)
)
);

To merge several objects, pass them to Question.fromObject as per the example below:

Question.fromObject<AddProductRequestData>(
// initial values
{ name: 'unknown', quantity: 0 },
// overrides
{ name: Text.of(someElement) },
// other overrides
{ quantity: Text.of(someOtherElement).as(Number) },
)

Note that in the above code sample, the first object contains values for all the fields required by AddProductRequestData interface.

If not all the fields are required, make sure to mark them as optional.

For example:

interface AddProductRequestData {
name: string;
quantity?: number; // optional
}

Formatting descriptions with d and f

Deprecated tag function formatted has been removed and replaced by d (short for "description") and f (short for "format"):

- import { formatted } from '@serenity-js/core/lib/io';
+ import { f, d } from '@serenity-js/core/lib/io';

const AuthenticateAs = (username: Answerable<string>) =>
- Task.where(formatted `#actor authenticates as ${ username }`, /* ... */)
+ Task.where(d `#actor authenticates as ${ username }`, /* ... */)

While both the d and f tag functions can be used to format the descriptions of custom tasks, interactions and questions, the difference between them is how they format Questions provided as template parameter:

  • f marks the question parameter so that it's easy to distinguish in the description
  • d makes the question parameter blend in with the rest of the description

For example, given a custom question as follows:

const testUsername = () =>
Question.about('test username', actor => `${ actor.name }@example.org`)

Calling d:

    d`#actor authenticates as ${ testUsername() }`
// produces: #actor authenticates as test username

Calling f:

    f`#actor authenticates as ${ testUsername() }`
// produces: #actor authenticates as <<test username>>

@serenity-js/web

Other changes in the Serenity/JS Web module include:

Page-specific functions

Web page specific functions such as Website.url() and Website.title() are now aggregated under the Page API:

- import { Website } from '@serenity-js/webdriverio`
+ import { Page } from '@serenity-js/web`

await actorCalled('Alice').attemptsTo(
- Ensure.that(Website.title(), equals('Serenity/JS')),
+ Ensure.that(Page.current().title(), equals('Serenity/JS')),
)

The Page API also allows you to easily query properties of another browser window without interrupting the actor flow:

import { Page } from '@serenity-js/web'

await actorCalled('Alice').attemptsTo(
Ensure.that(
Page.whichUrl(endWith(`/gallery.html`)).title(),
equals('Summer collection')
),
)

You can also use it to switch to another tab and make the actor perform a sequence of interactions in that context:

import { endsWith } from '@serenity-js/assertions'
import { actorCalled } from '@serenity-js/core'
import { Page, Switch } from '@serenity-js/web'

await actorCalled('Bernie').attemptsTo(
Switch.to(Page.whichUrl(endsWith(`/gallery.html`))).and(
// perform verification of the gallery page
),
// automatically switch back to the original window
)

Your feedback matters!

If you like Serenity/JS, give it a ⭐ star on GitHub and tell your friends about it! For questions about Serenity/JS, check out Serenity/JS Q'n'A, and make sure to join our Serenity/JS Community Chat to say hi 👋

GitHub stars Join Serenity/JS Community Chat Join Serenity/JS Community Chat

New tutorials and videos are coming soon, follow us on LinkedIn and subscribe to Serenity/JS YouTube channel to get notified when they're available!

LinkedIn Follow YouTube Follow GitHub Sponsors