Serenity/JS 3.0 Release Candidate

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

The latest Release Candidate (RC) is already available on NPM and this guide will help you get started and highlight notable differences from Serenity/JS version 2.

We've already migrated several of the popular Serenity/JS templates, and you'll find code using the new Serenity/JS APIs on the main branch (the old master branch still contains Serenity/JS 2 code):

This guide will continue to evolve as we're working on Serenity/JS 3.0, so make sure to follow @SerenityJS on Twitter to stay up to date!

Follow SerenityJS on Twitter

Portable Web Tests

The most significant change in the Web testing space is the introduction of the @serenity-js/web module and numerous features that help your tests become portable across the different test integration tools (such as Protractor, WebdriverIO, Puppeteer, etc.)

The new module contains all the Web-related interactions and questions, while the integration-tool specific modules such as @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 (with support for Playwright already in the works).

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),
        );
    }
}

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, Text } from '@serenity-js/webdriverio';
import { Element } from '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, Text } 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 actions, including using advanced element filters and mapping, have a look at the PageElements patterns spec.

If you'd like to see a tutorial or screencast on this topic, let us know on Twitter!

Follow SerenityJS on Twitter

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, Note, Notepad } from '@serenity-js/core';

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

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

You can then record and retrieve notes using the interface describing the notepad:

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

actorCalled('Leonard')
  .whoCan(
    TakeNotes.using(Notepad.empty<MyCredentials>())
  )
  .attemptsTo(
    Note.record<MyCredentials>('username', 'leonard@example.org'),
    Log.the(Note.of<MyCredentials>('username')),
  )

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<MyCredentials>({
      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;
 }

 export class Actors implements Cast {

     // initialise a shared notepad when the Actors class is initialised
     private readonly sharedNotepad = Notepad.with<AuthCredentials>({
         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<AuthCredentials>()));
         }
     }
 }

Another improvement is that Note.of now returns a QuestionAdapter. The adapter creates a Screenplay Pattern-style proxy around the underlying value, so you can invoke its methods and the adapter will generate Interactions and Questions as needed:

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

actorCalled('Leonard')
  .whoCan(
    TakeNotes.using(Notepad.empty<MyCredentials>())
  )
  .attemptsTo(
    Note.record<MyCredentials>('username', 'leonard@example.org'),
    Log.the(
      Note.of<MyCredentials>('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 notepad:

 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:

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

actorCalled('Alice')
  .attemptsTo(
    Note.record('shopping list item', 'milk'),
    Log.the(
      Note.of('shopping list item')
    ),
  )

@serenity-js/assertions

property removed

property helper function has been removed since it's no longer needed.

QuestionAdapter, returned by Question.about creates a Proxy object around the returned value, which allows for methods and properties of the underlying object to be invoked on the QuestionAdapter itself, and the results of the invocation automatically converted into Question or Interaction so that they're compatible with other Serenity/JS interfaces.

const User = () =>
  Question.about('user', actor => {
    // Question.about can return a static value or a Promise<value>,
    // QuestionAdapter is compatible with both.
    return Promise.resolve({
      handle: '@jan-molak',
    });
  }
import { actorCalled } from '@serenity-js/core';
- import { Ensure, equals, property } from '@serenity-js/assertions';
+ import { Ensure, equals } from '@serenity-js/assertions';

actorCalled('Alice').attemptsTo
-  Ensure.that(User(), property('handle', equals('@jan-molak')))
+  Ensure.that(User().handle, equals('@jan-molak'))
)

QuestionAdapter also creates a Proxy object around the returned value, allowing for the properties and methods of the returned value to be invoked on the QuestionAdapter itself, and their return values to be wrapped in Interaction or Question automatically so that they're compatible with other Serenity/JS interfaces.

For example:

Ensure.that(User().toUpperCase().slice(1, 4), equals('JAN'))

More coming soon!

More content is coming soon, so please follow Serenity/JS on Twitter to stay up to date! Follow SerenityJS on Twitter

If you find our work useful and want to keep the new features coming, become a Serenity/JS GitHub Sponsor today!

Sponsor