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:
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
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.
We'll create a test scenario that uses two actors: one to set up the test data, and one to interact with the web UI.
To follow along with the coding, get one of the Serenity/JS Project Templates as they come with everything you need to get started with Serenity/JS.
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.
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
Instantiating and retrieving actorsโ
With Serenity/JS, you can instantiate new actors or retrieve the ones you've already referenced in the given scenario using the function
actorCalled
and providing the name of the actor:
import { actorCalled } from '@serenity-js/core'
actorCalled('Apisitt') // returns: Actor(name='Apisitt')
actorCalled('Wendy') // returns: Actor(name='Wendy')
Note that every Serenity/JS actor is uniquely identified by their name.
The first time you call actorCalled('Wendy')
, Serenity/JS instantiates a new Actor
and stores a reference to it internally under the name you gave it.
This way, whenever you call actorCalled('Wendy')
within the same scenario again, you'll get the same actor instance back.
import assert from 'node:assert/strict'
import { actorCalled } from '@serenity-js/core'
const wendy1 = actorCalled('Wendy') // first invocation of actorCalled
const wendy2 = actorCalled('Wendy') // second invocation of actorCalled
assert.equal(wendy1, wendy2) // wendy1 === wendy 2
To avoid typos and repetition when instantiating and retrieving actors in your test scenarios, you might want to consider using string enums or constants to store actor names:
import { actorCalled } from '@serenity-js/core'
enum ActorNames {
Apisitt = 'Apisitt, the test data manager',
Wendy = 'Wendy, the customer',
}
actorCalled(ActorNames.Apisitt) // returns: Actor(name='Apisitt')
actorCalled(ActorNames.Wendy) // returns: Actor(name='Wendy')
Using actors with test runnersโ
While you could use Serenity/JS and the actorCalled
function as part of any regular Node.js program,
you'll typically use it with a test runner such as Playwright Test,
Cucumber.js, Mocha, or Jasmine.
To help you ensure no state leakage between test scenarios, Serenity/JS test runner adapters will also automatically dismiss any actors instantiated within the scope of a test scenario and free up the resources they were using when the scenario finishes.
Note that since different test runners have different APIs, the way you retrieve actors might vary slightly depending on the test runner you use:
- Playwright Test
- Mocha
- Jasmine
- Cucumber
import { describe, it } from '@serenity-js/playwright-test'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
actorCalled('Apisitt') // returns: Actor(name="Apisitt")
actorCalled('Wendy') // returns: Actor(name="Wendy")
})
})
import { actorCalled } from '@serenity-js/core'
import { describe, it } from 'mocha'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
actorCalled('Apisitt') // returns: Actor(name="Apisitt")
actorCalled('Wendy') // returns: Actor(name="Wendy")
})
})
import 'jasmine'
import { actorCalled } from '@serenity-js/core'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
actorCalled('Apisitt') // returns: Actor(name="Apisitt")
actorCalled('Wendy') // returns: Actor(name="Wendy")
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
actorCalled('Apisitt') // returns: Actor(name="Apisitt")
// ...
})
Abilitiesโ
Actors have abilities that enable them to interact with the various interfaces of the system under test and the test execution environment.
From the technical perspective, abilities act as wrappers around any integration libraries required to communicate with the external interfaces of system under test, such as web browser drivers or an HTTP client, or hold state to allow actors to remember retrieved information. Abilities also enable portability of your test code across various lower-level integration libraries as they expose a standardised API.
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!
To allow Apisitt to interact with a REST API, we'll give him the ability to CallAnApi
,
wrapping an instance of an Axios HTTP client:
import axios from 'axios'
import { actorCalled } from '@serenity-js/core'
import { CallAnApi } from '@serenity-js/rest'
const axiosInstance = axios.create({ baseURL 'https://api.example.org/' })
const actor = actorCalled('Apisitt')
.whoCan(CallAnApi.using(axiosInstance))
// ...
At the same time, to allow Wendy to interact with the web UI we'll give her an instance of a Playwright browser, although she could use 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'
async function example() {
let browser
try {
browser = await chromium.launch({ headless: false })
const actor = actorCalled('Wendy')
.whoCan(BrowseTheWebWithPlaywright.using(browser))
// ...
}
finally {
if (browser) {
await browser.close()
}
}
}
Even though the actorCalled
function makes instantiating and retrieving actors straightforward, setting up browser drivers, HTTP clients, and similar libraries can be more involved.
Thankfully, Serenity/JS has a mechanism to help you with that, as you'll see in the next section.
Using default test runner configurationโ
Since configuring integration libraries like Axios or Playwright is a common task in test automation,
Serenity/JS test runner adapters use your test runner's configuration to set up the abilities for you.
And so, Serenity/JS adapters for the following test runners will automatically give every actor you retrieve via actorCalled
the below abilities so that you don't have to configure them yourself:
- Playwright Test:
BrowseTheWebWithPlaywright
,CallAnApi
atbaseURL
configured in the Playwright Test config file,TakeNotes
- WebdriverIO (with Cucumber, Jasmine, or Mocha):
BrowseTheWebWithWebdriverIO
,CallAnApi
atbaseUrl
configured in the WebdriverIO config file,TakeNotes
- Protractor (with Cucumber, Jasmine, or Mocha):
BrowseTheWebWithProtractor
,CallAnApi
atbaseUrl
configured in the Protractor config file,TakeNotes
To retrieve actor's ability to do something, you can use the Actor.abilityTo
method,
which returns an implementation of the ability if the actor has it, or throws a ConfigurationError
if they don't.
While you wouldn't retrieve the abilities directly in your test scenarios, you'd use this mechanism in your custom interactions and questions.
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { BrowseTheWeb } from '@serenity-js/web'
import { CallAnApi } from '@serenity-js/rest'
import { TakeNotes } from '@serenity-js/core'
import assert from 'node:assert/strict'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
assert.ok(actorCalled('Wendy').abilityTo(BrowseTheWeb))
assert.ok(actorCalled('Wendy').abilityTo(CallAnApi))
assert.ok(actorCalled('Wendy').abilityTo(TakeNotes))
// ...
})
})
import { describe, it } from 'mocha'
import { BrowseTheWeb } from '@serenity-js/web'
import { CallAnApi } from '@serenity-js/rest'
import { actorCalled, TakeNotes } from '@serenity-js/core'
import assert from 'node:assert/strict'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
assert.ok(actorCalled('Wendy').abilityTo(BrowseTheWeb))
assert.ok(actorCalled('Wendy').abilityTo(CallAnApi))
assert.ok(actorCalled('Wendy').abilityTo(TakeNotes))
// ...
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { BrowseTheWeb } from '@serenity-js/web'
import { CallAnApi } from '@serenity-js/rest'
import { actorCalled, TakeNotes } from '@serenity-js/core'
import assert from 'node:assert/strict'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
assert.ok(actorCalled('Wendy').abilityTo(BrowseTheWeb))
assert.ok(actorCalled('Wendy').abilityTo(CallAnApi))
assert.ok(actorCalled('Wendy').abilityTo(TakeNotes))
// ...
})
As this simple test scenario shows, Serenity/JS provides your actors with the default abilities to BrowseTheWeb
, CallAnApi
and TakeNotes
without you having to modify any configuration and without writing any additional code.
All you need to do is to create/retrieve the Actor
by calling actorCalled(<name>)
.
Playwright Test, WebdriverIO and Protractor test runners vary dramatically and have completely different and incompatible APIs. This would normally tie your test suite to a specific integration tool and prevent you from being able to run test scenarios written for one test runner using another.
To solve this problem and allow your test suite to be agnostic of the underlying integration tool,
Serenity/JS test runner adapters configure the default cast of actors with an ability to BrowseTheWeb
specific to the given test runner, so: BrowseTheWebWithPlaywright
,
BrowseTheWebWithWebdriverIO
, or BrowseTheWebWithProtractor
,
but exposing a consistent API across all test runners.
This design allows you to write test scenarios that interact with the web UI without having to worry about the underlying integration tool and makes your test code portable across different test runners.
Using a custom cast of actorsโ
Following the system metaphor of a stage performance, Serenity/JS uses a cast of actors to configure the default abilities the actors receive upon instantiation.
If you'd like to modify those defaults, or if you use Serenity/JS with Cucumber, Jasmine, or Mocha without a WebdriverIO or Protractor wrapper that set up the defaults automatically, you can instruct Serenity/JS to use a custom cast.
- Playwright Test
- WebdriverIO
- Protractor
import { BrowseTheWebWithPlaywright } from '@serenity-js/playwright'
import { describe, it, test } from '@serenity-js/playwright-test'
import { Cast, TakeNotes, Notepad } from '@serenity-js/core'
test.use({
// Replace the entire default cast:
actors: async ({ contextOptions, page }, use): Promise<void> => {
const cast = Cast.where(actor => actor.whoCan(
BrowseTheWebWithPlaywright.usingPage(page, contextOptions),
TakeNotes.usingAnEmptyNotepad(),
// ... other abilities
))
await use(cast)
},
// Alternatively, wrap actorCalled to give actors additional abilities
// or override the default ones:
actorCalled: async ({ actorCalled }, use) => {
await use((name: string) => {
return actorCalled(name).whoCan(
TakeNotes.using(Notepad.with({
firstName: name,
lastName: 'Tester',
}))
// ... other abilities
)
})
},
})
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
actorCalled('Apisitt') // returns: Actor(name="Apisitt")
actorCalled('Wendy') // returns: Actor(name="Wendy")
})
})
import { BrowseTheWebWithWebdriverIO, WebdriverIOConfig } from '@serenity-js/webdriverio'
import { Cast, TakeNotes } from '@serenity-js/core'
import { browser } from '@wdio/globals'
export const config: WebdriverIOConfig = {
framework: '@serenity-js/webdriverio',
serenity: {
// Replace the entire default cast:
actors: Cast.where(actor => actor.whoCan(
BrowseTheWebWithWebdriverIO.using(browser),
TakeNotes.usingAnEmptyNotepad(),
// ... other abilities
)),
// Configure Serenity/JS to use an appropriate test runner adapter
runner: 'cucumber', // or: 'mocha', 'jasmine'
// ... other Serenity/JS configuration
},
}
const { BrowseTheWebWithProtractor } = require('@serenity-js/protractor')
const { Cast, TakeNotes } = require( '@serenity-js/core')
const protractor = require('protractor')
exports.config = {
framework: 'custom',
frameworkPath: require.resolve('@serenity-js/protractor/adapter'),
serenity: {
actors: Cast.where(actor => actor.whoCan(
BrowseTheWebWithProtractor.using(protractor.browser),
TakeNotes.usingAnEmptyNotepad(),
// ... other abilities
)),
// Configure Serenity/JS to use an appropriate test runner adapter
runner: 'jasmine', // or 'mocha', 'cucumber'
// ... other Serenity/JS configuration
},
}
If you're using Cucumber, Jasmine, or Mocha without a WebdriverIO or Protractor wrapper,
you can configure the default cast of actors using the configure
and engage
functions, as per the test runner adapter configuration.
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.
To instruct an actor to attempt to perform a sequence of interactions, use the Actor.attemptsTo
method.
Note that this method returns a Promise
that resolves when the actor has completed the interactions, or rejects if any of the interactions fail,
so you can use it with await
in an async
function.
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:
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { Send, PostRequest } from '@serenity-js/rest'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: 'Apples', price: 'ยฃ2.50' } // that instruct actors how to use their abilities
])),
)
// ...
})
})
import { describe, it } from 'mocha'
import { actorCalled } from '@serenity-js/core'
import { Send, PostRequest } from '@serenity-js/rest'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: 'Apples', price: 'ยฃ2.50' } // that instruct actors how to use their abilities
])),
)
// ...
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
import { Send, PostRequest } from '@serenity-js/rest'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: product, price: `ยฃ${price}` } // that instruct actors how to use their abilities
])),
)
})
In the same manner, we can instruct Wendy to use interactions from the Serenity/JS Web module to navigate to the web interface of our system under test:
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { Send, PostRequest } from '@serenity-js/rest'
import { Navigate } from '@serenity-js/web'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: 'Apples', price: 'ยฃ2.50' } // that instruct actors how to use their abilities
])),
)
await actorCalled('Wendy').attemptsTo(
Navigate.to('https://example.org'), // - all Serenity/JS interactions have a consistent API,
) // no matter the interface they're interacting with
})
})
import { describe, it } from 'mocha'
import { actorCalled } from '@serenity-js/core'
import { Send, PostRequest } from '@serenity-js/rest'
import { Navigate } from '@serenity-js/web'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: 'Apples', price: 'ยฃ2.50' } // that instruct actors how to use their abilities
])),
)
await actorCalled('Wendy').attemptsTo(
Navigate.to('https://example.org'), // - all Serenity/JS interactions have a consistent API,
) // no matter the interface they're interacting with
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
import { Send, PostRequest } from '@serenity-js/rest'
import { Navigate } from '@serenity-js/web'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
await actorCalled('Apisitt').attemptsTo( // - actors attempt to perform interactions
Send.a(PostRequest.to('/products').with([ // - interactions like `Send` are command objects,
{ name: product, price: `ยฃ${price}` } // that instruct actors how to use their abilities
])),
)
})
// When Wendy looks for "Apples"
When('{word} looks for {string}', async (actor: string, product: string) => {
await actorCalled(actor).attemptsTo(
Navigate.to('https://example.org'), // - all Serenity/JS interactions have a consistent API,
) // no matter the interface they're interacting with
})
Note how the interaction to Navigate.to(url)
comes from the @serenity-js/web
module
and works regardless of the underlying web browser driver used by the actor, making your test code portable across different web integration tools.
Under the hood, the interaction requests the actor's ability to BrowseTheWeb
and Serenity/JS returns the appropriate implementation of the ability.
If you wanted to implement an interaction to navigateTo(url)
yourself, you might do it like this:
import { Interaction } from '@serenity-js/core'
import { BrowseTheWeb } from '@serenity-js/web'
const navigateTo = (url: string) =>
Interaction.where(`#actor navigates to ${ url }`, async actor => {
const page = await BrowseTheWeb.as(actor).currentPage()
return page.navigateTo(url)
})
Another thing to note about the code samples in this section is that even though Playwright Test, Mocha, and Cucumber have completely different APIs, the way we instruct actors to perform interactions is consistent across all test runners. This design makes it easier for you to migrate your test scenarios from one test runner to another if needed. It also enables you to make your test suite use different test runners for different types of test scenarios while reusing the same underlying test automation code.
For example, you might want to use Serenity/JS with Cucumber for business-facing scenarios, Playwright Test for UI component tests, and with Mocha for REST API tests.
Learn more about:
Questionsโ
Apart from enabling interactions, abilities also enable actors to answer questions about the state of the system under test and the test execution environment. More specifically, questions instruct actors how to use their abilities to retrieve information when the activity is performed and provide a way to parameterise activities.
When Apisitt uses his ability to CallAnApi
to Send
a PostRequest
,
the result of this interaction is stored in the ability. To retrieve its value, we instruct the actor to answer a question about the LastResponse
, such as LastResponse.status()
.
This declarative approach to information retrieval not only makes your test scenarios more readable, it provides a consistent API for you to design assertions, waiting and synchronisation statements,
and perform data transformations.
For example, to instruct Apisitt to assert on the result of the HTTP request, we provide him with an interaction to Ensure.that
,
parameterised with a question about the LastResponse.status()
and an expectation that the status code equals 201
:
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { Ensure, equals } from '@serenity-js/assertions'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: 'Apples', price: 'ยฃ2.50' }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
// ...
})
})
import { describe, it } from 'mocha'
import { actorCalled } from '@serenity-js/core'
import { Ensure, equals } from '@serenity-js/assertions'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: 'Apples', price: 'ยฃ2.50' }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
// ...
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
import { Ensure, equals } from '@serenity-js/assertions'
import { Send, PostRequest } from '@serenity-js/rest'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: product, price: `ยฃ${price}` }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
})
// ...
An excellent proof of the design consistency enabled by the Serenity/JS Screenplay Pattern is that 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 identical with how you'd interact with any other interface:
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { Ensure, equals, endsWith } from '@serenity-js/assertions'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
import { Navigate, Page } from '@serenity-js/web'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: 'Apples', price: 'ยฃ2.50' }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
await actorCalled('Wendy').attemptsTo(
Navigate.to('https://example.org'),
Ensure.that(
Page.current().title(),
endsWith('My Example Shop'),
)
)
})
})
import { describe, it } from 'mocha'
import { actorCalled } from '@serenity-js/core'
import { Ensure, equals, endsWith } from '@serenity-js/assertions'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
import { Navigate, Page } from '@serenity-js/web'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: 'Apples', price: 'ยฃ2.50' }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
await actorCalled('Wendy').attemptsTo(
Navigate.to('https://example.org'),
Ensure.that(
Page.current().title(),
endsWith('My Example Shop'),
)
)
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
import { Ensure, equals, endsWith } from '@serenity-js/assertions'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
import { Navigate, Page } from '@serenity-js/web'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
await actorCalled('Apisitt').attemptsTo(
Send.a(PostRequest.to('/products').with([
{ name: product, price: `ยฃ${price}` }
])),
Ensure.that(
LastResponse.status(),
equals(201)
)
)
})
// When Wendy looks for "Apples"
When('{word} looks for {string}', async (actor: string, product: string) => {
await actorCalled(actor).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 standard JavaScript functions, tasks offer an easy way to associate business meaning with sequences of activities and turn them into reusable building blocks from which your team can assemble test scenarios.
For example, we can use Serenity/JS convenient Task.where
method to define custom tasks that capture how an actor would set up a product catalogue or open an online store:
import { Task } from '@serenity-js/core'
import { Send, PostRequest, LastResponse } from '@serenity-js/rest'
import { Navigate, Page } from '@serenity-js/web'
import { Ensure, equals, endsWith } from '@serenity-js/assertions'
export interface Product {
name: string;
price: string;
}
export 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)
)
)
export const openOnlineStore = () =>
Task.where(`#actor opens the online store`,
Navigate.to('https://example.org'),
Ensure.that(
Page.current().title(),
endsWith('My Example Shop'),
)
)
As you can see, custom tasks like these are easy to read and understand, and can be parameterised and reused across different test scenarios, test suites, or even across different projects and teams. Tasks can help you capture the domain language, provide a consistent way to structure your test scenarios, and make your test code reusable and easier to maintain.
- Playwright Test
- WebdriverIO with Mocha
- WebdriverIO with Cucumber
import { describe, it } from '@serenity-js/playwright-test'
import { setupProductCatalogue, openOnlineStore } from './tasks'
describe('Online shop', () => {
it('should allow customers to find products of interest', async ({ actorCalled }) => {
await actorCalled('Apisitt').attemptsTo(
setupProductCatalogue([
{ name: 'Apples', price: 'ยฃ2.50' }
])
)
await actorCalled('Wendy').attemptsTo(
openOnlineStore(),
// ... other tasks, like findProductCalled('Apples'),
)
})
})
import { describe, it } from 'mocha'
import { actorCalled } from '@serenity-js/core'
import { setupProductCatalogue, openOnlineStore } from './tasks'
describe('Online shop', () => {
it('should allow customers to find products of interest', async () => {
await actorCalled('Apisitt').attemptsTo(
setupProductCatalogue([
{ name: 'Apples', price: 'ยฃ2.50' }
])
)
await actorCalled('Wendy').attemptsTo(
openOnlineStore(),
// ... other tasks, like findProductCalled('Apples'),
)
})
})
import { Given, When, Then } from '@cucumber/cucumber'
import { actorCalled } from '@serenity-js/core'
import { setupProductCatalogue, openOnlineStore } from './tasks'
// Given the product catalogue has "Apples" at ยฃ2.50
Given('the product catalogue has {string} at ยฃ{float}', async (product: string, price: number) => {
await actorCalled('Apisitt').attemptsTo(
setupProductCatalogue([
{ name: 'Apples', price: 'ยฃ2.50' }
])
)
})
// When Wendy looks for "Apples"
When('{word} looks for {string}', async (actor: string, product: string) => {
await actorCalled(actor).attemptsTo(
openOnlineStore(),
// ... other tasks, like findProductCalled(product),
)
})
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.
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:
- Playwright Test
- Mocha
- Jasmine
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(),
)
})
})
import { actorCalled } from '@serenity-js/core'
import { describe, it } from 'mocha'
describe('Serenity Airlines flight booking', () => { // system feature
it('should allow travellers to book a plane ticket', () => { // scenario goal
await actorCalled('Trevor').attemptsTo(
findFlight('London', 'New York'), // activities
chooseFlightClass(FlightClass.Economy),
providePaymentDetails(defaultCard),
receiveBookingConfirmation(),
)
})
})
import { actorCalled } from '@serenity-js/core'
describe('Serenity Airlines flight booking', () => { // system feature
it('should allow travellers to book a plane ticket', () => { // scenario goal
await actorCalled('Trevor').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.
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.
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 steps could 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
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.
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:
- REST API testing with Cucumber and Serenity/JS
- REST API testing with Mocha and Serenity/JS
- Web testing with Cucumber, Playwright, and Serenity/JS
- Web testing with Playwright Test and Serenity/JS
- Web testing with WebdriverIO, Mocha, and Serenity/JS
- Web testing with WebdriverIO, Cucumber, and Serenity/JS
To dive even deeper, check out the Serenity/JS repository and explore the examples.
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.
- 2007: AAFTT workshop - In praise of abstraction - Kevin Lawrence introduces the idea of using the language of interaction designers to model automated tests
- 2007: AAFTT workshop - Antony Marcano demonstrates the "Roles, Goals, Tasks, Actions" model, which later evolves into the Screenplay Pattern
- 2008: JNarrate - first experimental Java implementation of the "Roles, Goals, Tasks, Actions" model by Antony Marcano and Andy Palmer
- 2011: Cuke Salad - Ruby implementation of the "Roles, Goals, Tasks, Actions" model by Antony Marcano
- 2011: A bit of UCD for BDD & ATDD: Goals -> Tasks -> Actions - blog post by Antony Marcano explaining the motivation behind the "Roles, Goals, Tasks, Actions" model
- 2012: Screenplay4j - first public Java implementation by Antony Marcano and Andy Palmer
- 2012: User Centred Scenarios: Describing capabilities, not solutions - talk by Antony Marcano and James Martin
- 2013: ScreenplayJVM - Java implementation by Antony Marcano and Jan Molak
- 2013: A journey beyond the page object pattern - talk by Antony Marcano, Jan Molak and Kostas Mamalis
- 2015: Serenity BDD - John Ferguson Smart and Jan Molak, along with Andy Palmer and Antony Marcano, add native support for the Screenplay Pattern to Serenity BDD, popularising the pattern in the Java testing community
- 2016: Beyond Page Objects: Next Generation Test Automation with Serenity and the Screenplay Pattern by Andy Palmer, Antony Marcano, John Ferguson Smart, and Jan Molak
- 2016: Page Objects Refactored: SOLID Steps to the Screenplay/Journey Pattern - by Antony Marcano, Andy Palmer, John Ferguson Smart, and Jan Molak
- 2016: Screenplays and Journeys, Not Page Objects - blog post by Jeff Nyman
- 2016: Screenplay Pattern as described by Jan Molak
- 2016: Serenity/JS - Jan Molak starts the Serenity/JS project - the original JavaScript/TypeScript implementation of the Screenplay Pattern
- 2017: Testing modern webapps. At scale. - Jan Molak introduces the idea of "Blended Testing" - making Screenplay Pattern tests scenarios interact with multiple interfaces as a way to improve test performance and encourage code reuse
- 2017: Having Our Cake and Eating It - Nat Pryce introduces the idea of using the Screenplay Pattern to write tests that run in milliseconds
- 2019: ScreenPy - Python implementation of the Screenplay Pattern by Perry Goy, inspired by Serenity BDD and Serenity/JS implementations
- 2020: Boa Constrictor - a .NET implementation of Screenplay by Andrew Knight, inspired by Serenity BDD and Serenity/JS
- 2020: Understanding Screenplay - blog series by Matt Wynne
- 2021: Cucumber Screenplay and Sub-second TDD - implementation by Aslak Hellesรธy, building on the Screenplay Pattern, and further exploring Nat Pryce's idea of sub-second acceptance tests and Jan Molak's "blended testing"
- 2021: BDD in Action, 2nd Edition by John Ferguson Smart and Jan Molak includes several chapters and many examples of using the Screenplay Pattern in Java and TypeScript