Skip to main content

Screenplay Pattern

The Screenplay Pattern is an innovative, user-centred approach to writing high-quality automated acceptance tests. It steers your team towards effectively using layers of abstraction, helps your test scenarios capture the business vocabulary of your domain, and encourages good testing and software engineering habits.

Focusing on actors and their goals and incorporating your domain language into test scenarios improves team collaboration and alignment, enabling technical and business stakeholders to understand and readily contribute to the test automation process.

Serenity/JS implementation of the Screenplay Pattern enables developers to easily introduce this design approach even into existing test automation projects. Moreover, the framework provides integration libraries to facilitate various test automation types, including end-to-end, component, mobile and API testing, making it a versatile choice for different testing needs. Serenity/JS also provides reporting tools and code reuse patterns that facilitate sharing test code across projects and teams and reducing maintenance costs.

The design principle

The design principle behind the Screenplay Pattern is simple but might forever change the way you look at test automation:

Remember

Automated acceptance tests should use your domain language to clearly express what activities the actors interacting with your system need to perform in order to accomplish their goals.

Applying this design principle to your automated tests has a number of positive implications:

  • Expressing your test scenarios in your domain language makes them easier to understand and accessible to a wider audience
  • Focusing on actors and their goals makes it easy to correlate any test failures with the actual business impact
  • Modelling actor workflows using sequences of business-focused, reusable activities reduces code duplication, improves flexibility of your test code base, and means that your team can quickly compose new test scenarios from existing steps

To communicate effectively, the code must be based on the same language used to write the requirements—the same language that the developers speak with each other and with domain experts.

Eric Evans, "Domain-Driven Design: Tackling Complexity in the Heart of Software"

The five elements of the Screenplay Pattern

The Screenplay Pattern uses the system metaphor of a stage performance, helping you model each test scenario like a little screenplay describing how the actors should go about performing their activities while interacting with the system under test.

Following the Screenplay Pattern helps you capture:

  • Who the actors interacting with your system are
  • Why they interact with your system
  • What they need to do to accomplish their goals
  • How exactly they would go about that

The five building blocks of the Screenplay Pattern are:

  • Actors, who represent people and external systems interacting with the system under test
  • Abilities, that act as thin wrappers around any integration libraries required to interact with the system under test
  • Interactions, which represent the low-level activities an actor can perform using a given interface
  • Tasks, used to model sequences of activities as meaningful steps of a business workflow in your domain
  • Questions, used to retrieve information from the system under test and the test execution environment
Five elements of the Screenplay Pattern

Screenplay Pattern with Serenity/JS

The best way to illustrate the Screenplay Pattern is through a practical example, so assume for a moment that we're writing a test scenario for an online shop. The shop has a REST API that lets us configure its product catalogue with some test data, and a web storefront that lets customers find the products they need and make a purchase.

Actors

A test scenario following the Screenplay Pattern has one or multiple actors representing people and external systems interacting with the system under test and playing specific roles.

The role of an actor

Just like the five core elements of the Screenplay Pattern, the term "role" comes from the system metaphor of a stage performance. It should be interpreted as the role a given actor plays in the performance, the role they play in the system. Some good examples of roles include "a doctor", "a trader", or "a writer".

While a "role" might imply the permissions a given actor has in the system they interact with (e.g. a "writer" can write articles, but only a "publisher" publishes articles), this is not a mechanism to prevent the actor from performing activities inconsistent with their role.

In particular, Serenity/JS will not prevent you from writing scenarios where a "writer" tries to impersonate a "publisher" and publish an article. If it did, you would not be able to test if your system correctly implemented its access control mechanisms!

Our example scenario could have two actors, who we'll call:

  • Apisitt, responsible for setting up test data using the REST API
  • Wendy, representing a customer interacting with the web UI

With Serenity/JS, we can instantiate new actors or retrieve the ones we've already referenced in the given scenario using the function actorCalled and providing their name:

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

actorCalled('Apisitt')
actorCalled('Wendy')

Learn more about:

Abilities

Actors have abilities that enable them to interact with the various interfaces of the system under test.

Did you know?

The word "screen" in "screenplay" has nothing to do with the computer screen. On the contrary, the Screenplay Pattern is a general method of modelling acceptance tests interacting with any external interface of your system. In fact, Serenity/JS implementation of the Screenplay Pattern can help you break free from UI-only-based testing!

For example, Apisitt will interact with the REST API using an Axios HTTP client:

import { actorCalled } from '@serenity-js/core'
import { CallAnApi } from '@serenity-js/rest'
import axios from 'axios'

const httpClient = axios.create({ baseURL: 'https://api.example.org' })

actorCalled('Apisitt')
.whoCan(CallAnApi.using(httpClient))

At the same time, Wendy will interact with the web UI using Playwright (although she could do so using any other popular web browser driver supported by Serenity/JS):

import { actorCalled } from '@serenity-js/core'
import { BrowseTheWebWithPlaywright } from '@serenity-js/playwright'

import { chromium } from 'playwright'

const browser = await chromium.launch({ headless: true })

actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))

Learn more about:

Interactions

Abilities enable actors to perform interactions with the system under test. Interactions are command objects that instruct an actor how to use their abilities to perform the given activity. Most interactions you will need are already provided by Serenity/JS modules, and you can easily create new ones if you'd like to.

Here, we instruct Apisitt to use the interaction to Send.a(HTTPRequest) from the Serenity/JS REST module to set up some test data for our test scenario:

// ...
import { CallAnApi, PostRequest } from '@serenity-js/rest'

// ...

await actorCalled('Apisitt')
.whoCan(CallAnApi.using(httpClient))
.attemptsTo( // actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // interactions like `Send` are command objects
{ name: 'Apples', price: '£2.50' }
])),
)

We can also instruct Wendy to use interactions from the Serenity/JS Web module to navigate to the web interface of our system under test:

// ...
import { BrowseTheWebWithPlaywright } from '@serenity-js/playwright'
import { Navigate } from '@serenity-js/web'

// ...

await actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))
.attemptsTo(
Navigate.to('https://example.org'), // all Serenity/JS interactions have a consistent API,
) // no matter the interface they're interacting with

Learn more about:

Questions

Apart from enabling interactions, abilities also enable actors to answer questions about the state of the system under test. Questions provide a way to parameterise activities and allow actors to retrieve information when the activity is performed.

For example, we could instruct Apisitt to answer the question about the LastResponse.status() and use it to Ensure.that his last request returned an expected status code (note that Serenity/JS assertions work just like any other interaction):

// ...
import { CallAnApi, PostRequest, Send, LastResponse } from '@serenity-js/rest'
import { Ensure, equals } from '@serenity-js/assertions'

// ...

await actorCalled('Apisitt')
.whoCan(CallAnApi.using(httpClient))
.attemptsTo(
Send.a(PostRequest.to('/products').with([ { name: 'Apples', price: '£2.50' } ])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)

Even though Wendy uses a web browser and not an API client, the way we instruct her to answer web questions and perform web assertions is consistent with how you'd interact with any other interface:

// ...
import { BrowseTheWebWithPlaywright } from '@serenity-js/playwright'
import { Navigate, Page } from '@serenity-js/web'
import { Ensure, endsWith } from '@serenity-js/assertions'

// ...

await actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))
.attemptsTo(
Navigate.to('https://example.org'),
Ensure.that(
Page.current().title(),
endsWith('My Example Shop'),
)
)

Learn more about:

Tasks

The idea that underpins the Screenplay Pattern is to capture your domain language and use your acceptance tests as an opportunity to demonstrate how actors interacting with your system accomplish their goals.

Conceptually similar to functions, tasks offer an easy way to associate business meaning with sequences of activities and turn them into reusable building blocks.

Here, we extract some of the activities performed by Apisitt and turn them into a reusable task to setupProductCatalogue:

import { actorCalled, Task } from '@serenity-js/core'
import { CallAnApi, PostRequest } from '@serenity-js/rest'
import { Ensure, equals } from '@serenity-js/assertions'

// ...

interface Product {
name: string;
price: string;
}

const setupProductCatalogue = (products: Product[]) =>
Task.where(`#actor sets up the product catalogue`,
Send.a(PostRequest.to('/products').with(products)),
Ensure.that(
LastResponse.status(),
equals(201)
)
)

await actorCalled('Apisitt')
.whoCan(CallAnApi.using(httpClient))
.attemptsTo(
setupProductCatalogue([ { name: 'Apples', price: '£2.50' } ])
)

In the exact same manner we define a task for Wendy to openOnlineStore:

import { actorCalled, Task } from '@serenity-js/core'
import { Navigate, Page } from '@serenity-js/web'
import { Ensure, endsWith } from '@serenity-js/assertions'
// ...

const openOnlineStore = () =>
Task.where(`#actor opens the online store`,
Navigate.to('https://example.org'),
Ensure.that(
Page.current().title(),
endsWith('My Example Shop'),
)
)

await actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))
.attemptsTo(
openOnlineStore(),
)

Learn more about:

Performing activities at multiple levels

The role of an actor is to perform activities that demonstrate how to accomplish a given goal.

Remember

Actors represent people and external systems interacting with the system under test.

For example, we might have an acceptance test that demonstrates how the system under test enables an actor to accomplish the goal of booking a plane ticket. If we were using Serenity/JS with a spec-style test runner like Jasmine, Mocha, or Playwright Test, we could implement such scenario like this:

import { describe, it, test } from '@serenity-js/playwright-test'

test.use({ defaultActorName: 'Trevor' })

describe('Serenity Airlines flight booking', () => { // system feature

it('should allow travellers to book a plane ticket', async ({ actor }) => { // scenario goal
await actor.attemptsTo(
findFlight('London', 'New York'), // activities
chooseFlightClass(FlightClass.Economy),
providePaymentDetails(defaultCard),
receiveBookingConfirmation(),
)
})
})

If we were using Cucumber.js, the name of the feature, the goal of the scenario, as well as the high-level steps necessary to achieve the goal would already be captured in our .feature files:

Feature: Serenity Airlines flight booking                                       # system feature

Scenario: traveller books a plane ticket # scenario goal

Given Trevor finds a flight from 'London' to 'New York' # high-level steps
And he chooses the 'Economy' flight class
When he provides his payment details
Then he should receive a booking confirmation

In this case, each Cucumber step definition is mapped to a Serenity/JS actor performing one or more activities. Completing those activities helps the actor accomplish the mini-goal of the associated Cucumber step:

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

Given('{actor} finds a flight from {string} to {string}', // step goal
async (actor: Actor, origin: string, destination: string) => {
await actor.attemptsTo(
findFlight(origin, destination), // activities
)
}
)

Given(`{pronoun} chooses the '{flightClass}' flight class`, // step goal
async (actor: Actor, flightClass: FlightClass) => {
await actor.attemptsTo(
chooseFlightClass(flightClass), // activities
)
}
)

From the implementation perspective, functions like findFlight or providePaymentDetails produce activities, which are command objects that encapsulate information needed for the actor to perform some defined action.

The way you instruct an actor to perform some activities is always exactly the same, no matter the kind of test you write or the type of interface the actor interacts with - you pass them to the Actor.attemptsTo method. This method call makes the actor attempt to perform the activities one by one and returns a standard JavaScript Promise that's resolved when the process is finished, or rejected in case of any errors:

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

await actorCalled('Alice').attemptsTo(
activity1,
activity2,
activity3,
// ...
)
// returns: Promise<void>

Looking at activities from the design philosophy perspective, however, can be much more interesting.

The role of an actor is not just to perform any activities. It is to perform activities that demonstrate how a goal can be accomplished at the given level of abstraction. The interesting bit is that the way we describe what the goal is and the vocabulary we use to describe what activities it requires vary depending on the level of abstraction we're operating at.

Remember

Actors demonstrate how to accomplish a goal by performing activities at multiple levels of abstraction.

At the high levels of abstraction, e.g. in business-focused acceptance test scenarios, the vocabulary we use is rooted in the business domain, and so are the names we choose for the activities.

For example, an acceptance test scenario might state that for the system to enable the actor to accomplish the goal of booking a plane ticket, an actor should be able to successfully perform the following high-level activities:

  • find an appropriate flight connection,
  • choose flight class,
  • provide payment details,
  • receive booking confirmation.

The names we give functions that produce those activities, such as findFlight or chooseFlightClass, represent those steps in the business process and are agnostic of the interface through which actors interact with the system under test.

Model the expected process, not the existing implementation

When describing an acceptance test at a high level of abstraction, the way we name the activities is focused on representing the steps of the expected business process and not tied to the implementation of any specific interface of the system under test. "Find an appropriate flight connection", "choose flight class", or "provide payment details" are all good examples of such high-level activity names.

This design approach helps to produce test scenarios that are easier to read and understand and to a much wider audience than the traditional test scripts. It also results in two other major advantages:

  • once the business process is clearly described in our test scenario, we can often use our acceptance tests to identify obstacles in user journeys, or even highlight errors and hidden assumptions in the business process itself
  • since we're not tying the implementation to any particular interface, we leave ourselves more integration options when it comes to automation.

After all, most business process stepscould be accomplished in different ways. An actor could findFlight by interacting with a web UI, a mobile app, by sending requests to a web service, or even by actually going to the ticket office at the airport!

At the low level of abstraction, e.g. in UI component tests, the vocabulary we use to describe actor's activities is focused on the interface the actor needs to interact with. Here the goal might be to use the web UI to specify the origin airport. To accomplish it, the actor would need to:

  • click on the origin airport widget
  • enter the name of the origin city, like London
  • pick the first suggested airport from the list
Remember

The core idea behind the Screenplay Pattern is to express the acceptance tests from the perspective of actors playing a certain role and attempting to accomplish their goals by performing activities at multiple levels of abstraction.

Of course, most activities fall somewhere in between the high and low levels of abstraction. Furthermore, turns out that higher-level activities can be composed of lower-level activities, which themselves could be composed of even lower-level activities!

If you're familiar with User Experience Design, you might recognise this style of functional decomposition from Hierarchical Task Analysis.

The fascinating aspect of looking at your test scenarios as sequences of activities made up of activities, made up of activities, is that this mental model lends itself perfectly to functional composition and making activities the primary component of code reuse in Serenity/JS.

Screenplay Pattern in test scenarios

Screenplay Pattern helps you to create acceptance tests that demonstrate how actors interacting with your system go about accomplishing their goals. Serenity/JS Screenplay Pattern APIs make it easy to write single- and multi-actor scenarios that interact with any interface of the system under test, help you capture your domain language, and produce test reports and living documentation that's accessible to everyone involved in your project.

Serenity/JS also supports a number of popular test runners, including Playwright Test where your test scenario could look like this:

spec/online_store.spec.ts
import { describe, it } from '@serenity-js/playwright-test'
// ...

describe('Online Store', () => {
// ...

it('should allow customers to find products of interest', async ({ actorCalled, browser }) => {

await actorCalled('Apisitt')
.whoCan(CallAnApi.using(httpClient))
.attemptsTo(
setupProductCatalogue([ { name: 'Apples', price: '£2.50' } ]),
)

await actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))
.attemptsTo(
openOnlineStore(),
findProductCalled('Apples'),
Ensure.that(topSearchResult().name, equals('Apples')),
Ensure.that(topSearchResult().price, equals('£2.50')),
)
})
})

Another benefit of using Serenity/JS Screenplay Pattern APIs is that they work great not just with spec-style test runners like Jasmine, Mocha, or Playwright Test.

The user-centred task-based code reuse model will also feel like a natural fit for teams using Serenity/JS with Cucumber:

features/online_store.feature
Feature: Online Store

Scenario: customer finds product by name

Given Apisitt sets up product catalogue with:
| name | price |
| Apples | £2.50 |
When Wendy looks for 'Apples'
Then she should see top search result of:
| name | Apples |
| price | £2.50 |
features/online_store.steps.ts
import { Given, When, Then, DataTable } from '@cucumber/cucumber'
import { Actor } from '@serenity-js/core'
// ...

Given('{actor} sets up product catalogue with:', (actor: Actor, products: DataTable) =>
actor.attemptsTo(
setupProductCatalogue(products.hashes()),
))

When('{actor} looks for {string}', (actor: Actor, productName: string) =>
actor.attemptsTo(
openOnlineStore(),
findProductCalled(productName),
))

Then('{pronoun} should see top search result of', (actor: Actor, expectedResult: string) =>
actor.attemptsTo(
Ensure.that(topSearchResult().name, equals(expectedResult.rowsHash().name)),
Ensure.that(topSearchResult().price, equals(expectedResult.rowsHash().price)),
))

Start with Serenity/JS Screenplay Pattern 🚀

The easiest way to experience working with Serenity/JS and the Screenplay Pattern is to follow the tutorial and write your first web scenario!

When you're ready to start your own test automation project, use one of the available Serenity/JS Project Templates as they include a handful of Screenplay scenarios and combine some of the most popular configurations of Serenity/JS modules and test automation tools:

To dive even deeper, check out the Serenity/JS repository and explore the examples.

Try Serenity/JS in your browser

Thanks to Gitpod.io, you can follow the web testing tutorial and use any of the Serenity/JS Project Templates right here in your browser, no local installation required! 🚀

History of the Screenplay Pattern

Serenity/JS introduced the Screenplay Pattern to JavaScript back in 2016, but the ideas behind the pattern have been around since 2007 in various forms.

This list is a chronological order of significant events, implementations, and writings related to the evolution of the Screenplay Pattern.

Credits