From Scripts to Serenity: Writing what you'd like to read

In the previous article we looked at how taking the scripting approach to test automation can result in a code base that's brittle and difficult to maintain. We've also talked about how the Page Object(s) pattern succeeded in addressing some of those problems, but also introduced some new ones.

In this article we'll look at how Serenity/JS and the Screenplay Pattern can help us design test suites that are easier to extend, maintain and scale to meet the requirements of a modern business.

Something practical

Let's look again at the Cucumber scenario we know from the previous article. This time we'll automate it from the outside-in, while gradually introducing the concepts of the Screenplay Pattern.

:bulb: PRO TIP: If you like learning by doing, clone the tutorial project and code along!

$> git clone https://github.com/serenity-js/tutorial-from-scripts-to-serenity
$> cd tutorial-from-scripts-to-serenity
$> npm install
$> npm test

The last command - npm test should tell you that there is one scenario pending. We're going to implement it now.

You'll find the example scenario in the features directory:

# features/add_new_items.feature

Feature: Add new items to the todo list

  In order to avoid having to remember things that need doing
  As a forgetful person
  I want to be able to record what I need to do in a place where I won't forget about them

  Scenario: Adding an item to a list with other items

    Given that James has a todo list containing Buy some cookies, Walk the dog
     When he adds Buy some cereal to his list
     Then his todo list should contain Buy some cookies, Walk the dog, Buy some cereal

Getting started

First, let's add Serenity/JS library to the project:

$> npm install serenity-js --save

Now have a look at the Cucumber steps defined in features/step_definitions/todo_user.steps.ts. These are the default empty step defintions generated by Cucumber (you can learn more about how Cucumber step definitions work on the CucumberJS site. In the examples below, we will be using TypeScript, hence the .ts file extensions. The code should be easy to follow even if you are not yet familiar with TypeScript. If you are curious, you might want to watch Anders Hejlsberg's "Introducing TypeScript".

// features/step_definitions/todo_user.steps.ts

export = function todoUserSteps() {

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, function (name: string, items: string, callback) {
        callback(null, 'pending');
    });

    this.When(/^s?he adds (.*?) to (?:his|her) list$/, function (itemName: string, callback) {
        callback(null, 'pending');
    });

    this.Then(/^.* todo list should contain (.*?)$/, function (items: string, callback) {
        callback(null, 'pending');
    });
};

The Screenplay Pattern is a user-centered model, which puts an emphasis on Actors - the external parties interacting with our system, their Goals and Tasks they perform to achieve them.

To automate a Cucumber scenario using the Screenplay Pattern, we first need an Actor:

let james = Actor.named('James');

If we added our actor to the Cucumber step definition, we'd get something like this:

// features/step_definitions/todo_user.steps.ts

import { Actor } from 'serenity-js/lib/screenplay';

export = function todoUserSteps() {

    let actor: Actor;

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, function(actorName: string, items: string, callback) {
        actor = Actor.named(actorName);

        callback();
    });

    // ...
};

:bulb: PRO TIP: Notice how we can now get the name of the actor ('James') from the Cucumber scenario, rather than directly hard-coding it? This means we can use this step definition for any actor, not just James.

Actors have their Goals, and need to perform Tasks to achieve these goals. In this case, James has a highly ambitious goal to add "Buy some cereal" to his todo list. But before he can do this, our scenario needs James to start with a todo list that already contains a couple of items - more specifically, "Buy some cookies" and "Walk the dog":

Given that James has a todo list containing Buy some cookies, Walk the dog

Let's make the implementation reflect this (don't worry about your editor complaining that the Start class doesn't exist yet, we'll implement it in the next step):

// features/step_definitions/todo_user.steps.ts

import { Start } from '../../spec/screenplay/tasks/start';
import { listOf } from '../../spec/text';

// ...

    let actor: Actor;

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, function(actorName: string, items: string, callback) {
        actor = Actor.named(actorName);

        actor.attemptsTo(
            Start.withATodoListContaining(listOf(items))
        );

        callback();
    });

// ...

Notice the code we used to actually add the initial items to the list? This might be a little different to the sort of test code you have seen before:

        actor.attemptsTo(
            Start.withATodoListContaining(listOf(items))
        );

Here, rather than executing a sequence of Protractor or WebDriver calls to type into fields and click on buttons, we instantiate a Task object - Start.withATodoListContaining(items) - using a naming and coding style that closely mimics the domain language of our application.

When you call actor.attemptsTo(...), the actor performs the sequence of tasks that you pass in as parameters. While this requires a bit of a shift in your way of thinking initially, it really helps you write code that's easy to read and understand.

Let's look at how a task like this is written.

The first Task

Tasks are implemented as simple TypeScript classes. A Task class needs to implement the Task interface, so that it can be performed by an Actor. Let's create a directory to store the tasks - spec/screenplay/tasks, and a start.ts file in it with the following contents:

// spec/screenplay/tasks/start.ts

import { PerformsTasks, Task } from 'serenity-js/lib/screenplay';

export class Start implements Task {

    static withATodoListContaining(items: string[]) {       // static method to improve the readability
        return new Start(items);
    }

    performAs(actor: PerformsTasks): PromiseLike<void> {    // required by the Task interface
        return actor.attemptsTo(                            // delegates the work to lower-level tasks
            // todo: add each item to the Todo List
        );
    }

    constructor(private items: string[]) {                  // constructor assigning the list of items
    }                                                       // to a private field
}

:bulb: PRO TIP: You'll find that some of the directories the below code samples refer to don't exist yet. Please go ahead and create them when needed.

Building a Domain-Specific Language

While we could simply create new instances of the tasks and pass them to the actor to perform, by convention we use a static factory method so that we can write Start.withATodoListContaining(items) rather than new Start(items):

    static withATodoListContaining(items: string[]) {
        return new Start(items);
    }

"Don't call me back"

Since the performAs method is required to return a PromiseLike, thenable object, we can remove the callback from the Cucumber step definition and make the code a bit simpler and cleaner:

// features/step_definitions/todo_user.steps.ts

// ...

    let actor: Actor;

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, function (actorName: string, items: string) {
        actor = Actor.named(actorName);

        return actor.attemptsTo(
            Start.withATodoListContaining(listOf(items));
        );
    });

// ...

Don't Repeat Yourself

All the Tasks implement a common interface - Task. This makes them easy to reuse and compose: tasks can do their job by calling other tasks, which in turn allows you to build high-level Tasks from the lower-level ones.

That's exactly what we'll do next as it seems that both the first and the second step of our Cucumber scenario require James to add items to his Todo List:

Given that James has a todo list containing Buy some cookies, Walk the dog
 When he adds Buy some cereal to his list

Let's expand our vocabulary of custom Tasks then. First, write the code you'd like to have in the Cucumber step. In this case, we want to add a todo item called 'Buys some cereal', so we could imagine a task that reads like this:

AddATodoItem.called('Buy some cereal')

Using this task, the full step definition would look like this:

// features/step_definitions/todo_user.steps.ts

import { AddATodoItem } from '../../spec/screenplay/tasks/add_a_todo_item';
import { listOf } from '../../spec/text';

// ...

    let actor: Actor;

    // ...

    this.When(/^he adds (.*?) to his list$/, (itemName: string) => {
        return actor.attemptsTo(
            AddATodoItem.called(itemName)
        )
    });

// ...

Now we can define the AddATodoItem Task:

// spec/screenplay/tasks/add_a_todo_item.ts

import { PerformsTasks, Task } from 'serenity-js/lib/screenplay';

export class AddATodoItem implements Task {

    static called(itemName: string) {                       // static method to improve the readability
        return new AddATodoItem(itemName);
    }

    performAs(actor: PerformsTasks): PromiseLike<void> {    // required by the Task interface
        return actor.attemptsTo(                            // delegates the work to lower-level tasks
            // todo: interact with the UI
        );
    }

    constructor(private itemName: string) {                 // constructor assigning the name of the item
    }                                                       // to a private field
}

And now that we have the AddATodoItem, we can reuse it in the original Start Task:

// spec/screenplay/tasks/start.ts

import { PerformsTasks, Task } from 'serenity-js/lib/screenplay';
import { AddATodoItem } from './add_a_todo_item';

export class Start implements Task {

    static withATodoListContaining(items: string[]) {
        return new Start(items);
    }

    performAs(actor: PerformsTasks): PromiseLike<void> {
        return actor.attemptsTo(
            ...this.addAll(this.items)                          // ``...` is a spread operator,
        );                                                      // which converts a list to vararg
    }

    constructor(private items: string[]) {
    }

    private addAll(items: string[]): Task[] {                   // transforms a list of item names
        return items.map(item => AddATodoItem.called(item));    // into a list of Tasks
    }
}

Now that we have a seed of a nice and readable Domain-Specific Language, it's time to make our acceptance test interact with the application.

Interacting with the system

In order for James to be able to interact with the TodoMVC app, we need to give him the ability to use a web browser:

Actor.named('James').whoCan(BrowseTheWeb.using(protractor.browser));

In the example above, BrowseTheWeb is an Ability, which enables James to interact with protractor.browser object and therefore with the web interface of the application.

Why do we need this indirection here? Why not use the global protractor.browser object directly in our code? First of all, making the dependency on protractor.browser explicit makes our intent more obvious to the reader. Secondly, it enables multi-browser testing of applications like chat systems, workflow systems or multi-player games. We'll talk about it more in future articles.

:bulb: PRO TIP: Thanks to the Abilities, you can teach the Actors to use different interfaces of your system. For example, you could imagine using a REST API to set up the test data, web UI to perform the test and maybe then FTP to a server to see if the image was correctly uploaded.

Let's update the Cucumber step definitions to include our new code:

// features/step_definitions/todo_user.steps.ts

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

import { protractor } from 'protractor';

// ...

export = function todoUserSteps() {

    let actor: Actor;

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, (actorName: string, items: string) => {

        actor = Actor.named(actorName).whoCan(BrowseTheWeb.using(protractor.browser));

        return actor.attemptsTo(
            Start.withATodoListContaining(listOf(items))
        );
    });

    // ...
};

Now that James has the ability to interact with the application, it's time to use it.

Let's wire up the Start task first and make it do something useful. Opening the browser and navigating to the application could be a good start:

// ...

import { Open } from 'serenity-js/lib/screenplay-protractor';

export class Start implements Task {

    // ...

    performAs(actor: PerformsTasks): PromiseLike<void> {
        return actor.attemptsTo(
            Open.browserOn('/examples/angularjs/'),
            ...this.addAll(this.items)                          // ``...` is a spread operator,
        );                                                      // which converts a list to vararg
    }

    // ...
}

If you've been coding along, you can see for yourself that the browser is really being used here to navigate to the TodoMVC app by running npm test:

$> npm test

How did that work?

Open is an Interaction, which means that is uses an Ability, in this case to BrowseTheWeb, to interact with the system.

Open is one of the Interactions that ship with Serenity/JS.

Adding items

Now that we can open the browser and navigate to the application it's time to add some items to the list.

As this interaction requires us to enter some text into the text field and hit the enter key, we can use another built-in Interaction - Enter:

// spec/screenplay/tasks/add_a_todo_item.ts

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

import { protractor } from 'protractor';

import { TodoList } from '../components/todo_list';

export class AddATodoItem implements Task {

    static called(itemName: string) {
        return new AddATodoItem(itemName);
    }

    performAs(actor: PerformsTasks): PromiseLike<void> {
        return actor.attemptsTo(
            Enter.theValue(this.itemName)                   // enter the value of the item name
                .into(TodoList.What_Needs_To_Be_Done)       // into a "What needs to be done" field
                .thenHit(protractor.Key.ENTER)              // then hit enter...
        );
    }                                                       // see? we didn't even need this explanation!

    constructor(private itemName: string) {
    }
}

There's one thing missing though: we haven't defined what TodoList.What_Needs_To_Be_Done means. Let's do this next:

// spec/screenplay/components/todo_list.ts

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

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

The TodoList class is the Screenplay equivalent of a Page Object - it encapsulates all the information you need to locate elements on a screen or on a component that appears on the screen. In this case, the TodoList is responsible for knowing how to find the HTML elements that make up the todo list in the TodoMVC application.

Another thing that you've probably noticed is the Target object. A Target is a light-weight wrapper around Protractor and WebDriver Locators. by.id('new-todo') represents such a locator, identifying an input field by its id attribute:

<input id="new-todo" placeholder="What needs to be done?" ng-model="newTodo" ng-disabled="saving" autofocus="" />

The difference between a Target and a Locator is that a Target can be given a meaningful description, such as "What needs to be done?" input box or Default payment method etc. This helps to generate narrative reports we'll talk about in the next tutorial. It also helps with diagnosing application failures.

If you run the test now you'll notice that both the first and the second step of our original scenario have now started to work and interact with the application, adding items to the Todo List:

$> npm test

:bulb: Because we've designed our test system from outside-in, composing high-level tasks such as Start from lower-level ones, such as AddATodoItem, we only had to implement the change in one place.

Asking the right Questions

Our test scenario will be much more useful when we add an assertion to it. What we'll do next is check if the items are getting added correctly to the list.

In order to verify the state of the application, an Actor can ask Questions:

// features/step_definitions/todo_user.steps.ts

import { expect } from '../../spec/expect';
import { TodoList } from '../../spec/screenplay/components/todo_list';

// ...

export = function todoUserSteps() {

    let actor: Actor;

    // ...

    this.Then(/^.* todo list should contain (.*?)$/, (items: string) => {
        return expect(actor.toSee(TodoList.Items_Displayed)).eventually.deep.equal(listOf(items));
    });
};

A Question is similar to an Interaction, as it uses the actor's ability to interact with the system.

When the actor answers a question, it returns a Promise, which resolves to a value, such as a string of text or a number, which then can be asserted on. That's why in the example above we could use Actor.toSee(question) together with chai.js and chai-as-promised.

There are several Questions that ship with Serenity/JS which you can use in your test scenarios.

Right now we'll use Text, which returns the text value of an element - Text.of() or elements - Text.ofAll() identified by a Target, and the Target itself

// spec/screenplay/components/todo_list.ts

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

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


    static Items = Target.the('List of Items').located(by.repeater('todo in todos'));
    static Items_Displayed = Text.ofAll(TodoList.Items);
}

As with Abilities, Tasks and Interactions, you can also define custom, domain-specific Questions and we'll talk about the ways to do that in future articles.

:bulb: PRO TIP: In the above example, we've defined both the questions and the targets in a single TodoList class, which represents the UI component that we're working with. Most of the modern UI frameworks such as Angular or React use a similar concept to represent an independent "widget" that a user can interact with.

You can run the test again and see the scenario succeed:

$> npm test

Before you go

To see what happens when a scenario fails, you can add a new one to the feature file and call npm test again:

# features/add_new_items.feature

# ...

  Scenario: Adding an item and failing
    Given that James has a todo list containing Walk the dog
     When he adds Buy some cereal to his list
     Then his todo list should contain Herd the cats, Buy a cake

This execution should result in a message notifying you that the test has failed, accompanied by a lengthy stack trace.

As reading through stack traces is not necessarily the most efficient way to diagnose failures, the next tutorial will focus on generating narrative, illustrated and meaningful reports of our interaction with the app.

Ready? Time to make the tests speak for themselves


Your feedback matters!

Suggest features and improvements on github, get in touch on twitter, and if you found Serenity/JS useful - don't forget to give it a star! ★

Star

results matching ""

    No results matching ""