@cucumber/screenplay
v5.3.0
Published
Write better scenarios with Cucumber.js
Downloads
5,021
Readme
@cucumber/screenplay
Cucumber Screenplay is a small library for Cucumber.js that enables better acceptance tests (Gherkin Scenarios):
- 🚅 Full-stack acceptance tests that run in milliseconds
- 🔓 Encourages loosely coupled system components that are easier to test in isolation
- 🧒 Incremental development - get feedback before you've implemented the full stack
- 🧩 Assembles system components in several ways, so you can optimize for speed or test coverage
- 📗 Readable scenarios that describe the what instead of the how
- 🧰 Maintainable automation code
See the credits section for details about prior work that inspired this library.
When you use Cucumber Screenplay, your step definitions are typically one-liners:
When('{actor} logs in successfully', async function (actor: Actor) {
await actor.attemptsTo(logIn(`${actor.name}@test.com`, 'valid-password'))
})
You can provide several implementations of logIn
- one that interacts with the user interface, but also one that
interacts with the API layer underneath the user interface via direct function calls or HTTP requests.
This forces you to avoid UI language in your scenarios like "fill in field" and "click button", because it doesn't make
sense to do that in a logIn
implementation that isn't using the UI. Likewise, it forces you to avoid using HTTP
language like "execute HTTP POST /login", because it doesn't make sense to do this in the logIn
implementation that
uses the UI.
These constraints encourage you to write readable scenarios that describe what users can do rahter than how your system is implemented. Your scenarios become living documentation that can be understood by everyone on the team.
Assemblies
With Cucumber Screenplay you can evolve an acceptance test suite that you can run with multiple configurations, or assemblies. The assembly diagrams below illustrate how:
- Test components
- Infrastructure components
- Production components
| DOM-HTTP-Domain | DOM-Domain | HTTP-Domain | Domain | | ---------------------------------------------- | ------------------------------------ | ---------------------------------------| ---------------------------- | | | | | |
Watch Cucumber creator Aslak Hellesøy explain how assemblies can be used to build acceptance tests that run in milliseconds:
Installation
First, add the library to your project:
npm install @cucumber/screenplay --save-dev
Usage
This guide will walk you through the usage of the @cucumber/screenplay
step by step. For a full example, please refer
to this repo.
Actors
The central concept in @cucumber/screenplay
is the Actor
. An actor object represents a user interacting with the
system.
In order to access actor objects from your step definitions, you first need to define an {actor}
parameter type.
Create a file called features/support/World.ts
(if you haven't already got one) and add the following code:
import { defineParameterType, setWorldConstructor } from '@cucumber/cucumber'
import { ActorWorld, ActorParameterType } from '@cucumber/screenplay'
// Define an {actor} parameter type that creates Actor objects
defineParameterType(ActorParameterType)
// Define your own World class that extends from ActorWorld
export default class World extends ActorWorld {
}
setWorldConstructor(World)
Your step definitions will now be passed Actor
objects for {actor}
parameters, for example:
When Martha logs in
When('{actor} logs in', async function (actor: Actor) {
// tell the actor to perform a task
})
Keep reading to learn how to define tasks.
Perfoming tasks
Now that your step definitions can be passed Actor
objects, we need to define tasks that the actor can perform
to achieve a particular goal.
A task is a function that returns another function that expects an Actor
parameter.
Add the following to features/support/tasks/logIn.ts
:
type LogIn = (email: string, password: string) => Action<string>
export const logIn: LogIn = (email, password) => {
return (actor: Actor) => {
// Just a dummy implementation for now - we'll come back and flesh this out later
return '42'
}
}
Back in the step definition we can now import this task:
import { logIn } from '../support/tasks/logIn'
When('{actor} logs in', async function (actor: Actor) {
const userId = await actor.attemptsTo(logIn(`${actor.name}@test.com`, 'valid-password'))
})
Tasks and Interactions
The screenplay pattern encourages you to decompose complex tasks into multiple interaction:
+--------+
| Action |
+--------+
^
|
+---------+---------+
| |
+-------+ +---+--+ +-----+-------+
| actor |------>| task |--------->| interaction |
+-------+ +------+ 0..N +-------------+
In @cucumber/screenplay
, both tasks and interactions are of type Action
. The library does not
make a distinction between them, it is up to you how you decompose tasks into interactions.
See shout.ts for an example of a task that delegates to two interactions.
Tasks and Questions
In addition to Actor#attemptsTo
there is also an Actor#ask
method. It has exactly the same signature
and behaviour as Actor#attemptsTo
. It often makes your code more readable if you use Actor#attemptsTo
in your When
step definitions that modify system state, and Actor#ask
in Then
step definitions
that query system state.
For example:
export type InboxMessages = () => Action<readonly string[]>
export const inboxMessages: InboxMessages = (userId) => {
return (actor: Actor) => {
return ['hello', 'world']
}
}
And in the step definition:
import { inboxMessages } from '../support/tasks/inboxMessages'
Then('{actor} should have received the following messages:', function (actor: Actor, expectedMessages: DataTable) {
const receivedMessages = actor.ask(inboxMessages())
assert.deepStrictEqual(receivedMessages, expectedMessages.rows.map(row => row[0]))
})
Using different task implementations
It can often be useful to have multiple implementations of the same task. This allows you to build new functionality incrementally with fast feedback.
For example, you might be working on a new requirement that allows users to log in. You can start by building just the server side domain logic before you implement any of the HTTP layer or UI around it and get quick feedback as you progress.
Later, you can run the same scenarios again, but this time swapping out your tasks with implementations that make HTTP requests or interact with a DOM - without changing any code.
If you look at the shouty example included in this repo, you will see that we organized our tasks in two directories:
features
├── hear_shout.feature
└── support
└── tasks
├── dom
│ ├── inboxMessages.ts
│ ├── moveTo.ts
│ └── shout.ts
└── session
├── inboxMessages.ts
├── moveTo.ts
└── shout.ts
If your World
class extends from ActorWorld
, it will automatically load tasks from the directory specified
by the tasks
world parameter:
# Use the dom tasks
cucumber-js --world-parameters '{ "tasks": "support/tasks/dom" }'
# Use the session tasks
cucumber-js --world-parameters '{ "tasks": "support/tasks/session" }'
Here is what the World
looks like (if you are using CommonJS modules):
import { setWorldConstructor } from '@cucumber/cucumber'
import { ActorWorld, defineActorParameterType, Action } from '@cucumber/screenplay'
import { InboxMessages, Shout, StartSession } from './tasks/types'
export default class World extends ActorWorld {
// These tasks will be loaded automatically
public startSession: StartSession
public shout: Shout
public inboxMessages: InboxMessages
}
setWorldConstructor(World)
If you are using ES Modules (by setting "type": "module"
in package.json
), you need to
change ypur World
constructor, and add a Before
step:
import { setWorldConstructor } from '@cucumber/cucumber'
import { ActorWorld, defineActorParameterType, Action } from '@cucumber/screenplay'
import { InboxMessages, Shout, StartSession } from './tasks/types'
export default class World extends ActorWorld {
// These tasks will be loaded automatically
public startSession: StartSession
public shout: Shout
public inboxMessages: InboxMessages
constructor(props: IWorldOptions) {
super({ ...props, packageType: 'module' })
}
}
setWorldConstructor(World)
Before(async () => {
if (this.promise) {
await this.promise
}
})
If you're using this technique, you also need to adapt your step definitions to reference tasks from the
world (this
):
When('{actor} shouts {string}', async function (this: World, actor: Actor, message: string) {
await actor.attemptsTo(this.shout(message))
})
Sharing data between steps
Your actors have the abililty to remember
and recall
data between steps. For example:
When('{actor} logs in', async function (actor: Actor<World>) {
const userId = await actor.attemptsTo(logIn(`${actor.name}@test.com`, 'valid-password'))
actor.remember('userId', userId)
})
Then('{actor} should be logged in', function (actor: Actor) {
assert.ok(actor.recall('userId'))
})
You can also pass a function to remember
to memoize a value:
function getSomething(actor: Actor) {
actor.recall('something', () => ({ foo: 'bar' }))
}
// The same object is returned every time
assert.strictEqual(getSomething(actor), getSomething(actor))
Note: the data remembered is scoped by Actor
, so you cannot access data remembered by one actor from another
one. You can have multiple actors storing different data with the same key. Every Actor
is discarded at the end of
each scenario, so you won't be able to recall
anything from previous scenarios.
Accessing the world from actors
If your tasks need to access data in the world
, they can do so via the Actor#world
property. If you're
doing this you should also declare the generic type of the actor in the task implementation:
export const moveTo: MoveTo = (coordinate) => {
// We're declaring the World type of the actor so that we can access its members
return async (actor: Actor<World>) => {
actor.world.shouty.moveTo(actor.name, coordinate)
}
}
Asynchronous behaviour and eventual consistency
In a distributed system it may take some time before the outcome of an action propagates around the whole system.
For example, in a chat application, when one user sends a message, it may take a few milliseconds before the other users receive the message, because it travels through a network, even when it's all on your machine.
In cases like this you can use the eventually
function to periodically check for a specific condition:
Then('{actor} hears {actor}’s message', async function (this: World, listener: Actor<World>, shouter: Actor) {
const shouterLastMessage = shouter.recall('lastMessage')
await eventually(() => {
const listenerMessages = listener.ask(this.inboxMessages())
assert.deepStrictEqual(listenerMessages, [shouterLastMessage])
})
})
The eventually
function accepts a single argument - a zero argument condition
function. If the condition
function
throws an error, it will be called again at a regular interval
until it passes without throwing an exception.
If it doesn't pass or finish within a timeout
period, a timeout error is thrown.
The default interval
is 50ms
and the default timeout
is 1000ms
. This can be overridden with a second
{ interval: number, timeout: number }
argument after the condition
.
Advanced Configuration
Below are some guidelines for more advanced configuration.
Using Promises
The default type of an Action
is void
. If your system is asynchronous
(i.e. uses async
functions that return a Promise
), you can use the PromiseAction
type instead of Action
.
Using an explicit ActorLookup
If you cannot extend ActorWorld
, you can add an ActorLookup
field to your existing world class like so:
import { ActorLookup } from '@cucumber/screenplay'
class World {
private readonly actorLookUp = new ActorLookup()
public findOrCreateActor(actorName: string): Actor<World> {
return this.actorLookUp.findOrCreateActor(this, actorName)
}
}
Overriding ActorParameterType options
The defineParameterType(ActorParameterType)
function call defines a parameter type named {actor}
by default,
and it uses the RegExp /[A-Z][a-z]+/
(a capitailsed string).
If you want to use a different name or regexp, you can override these defaults:
defineParameterType({ ...ActorParameterType, name: 'acteur' })
defineParameterType({ ...ActorParameterType, regexp: /Marcel|Bernadette|Hubert/ })
defineParameterType({ ...ActorParameterType, name: 'acteur', regexp: /Marcel|Bernadette|Hubert/ })
Design recommendations
When you're working with @cucumber/screenplay
and testing against multiple layers, we recommend you use only two
task implementations:
dom
for tasks that use the DOMsession
for tasks that use aSession
A Session
represents a user (actor) having an interactive session with your system. A Session
will typically be used
in two places of your code:
- From your
session
tasks - From your UI code (React/Vue components etc)
Session
is an interface that is specific to your implementation that you should implement yourself. Your UI code
will use it to interact with the server. This separation of concerns prevents network implementation details to
bleed into the UI code.
You'll typically have two implementations of your Session
interface - HttpSession
and DomainSession
.
The HttpSession
is where you encapsulate all of the fetch
, WebSocket
and EventSource
logic. This is the class
your UI will use in production. You will also use it in tests.
The DomainSession
is an implementation that talks directly to the server side domain layer with direct function calls
(without any networking). This implementation will only be used in tests.
By organising your code this way, you have four ways you can assemble your system when you run your Cucumber Scenarios.
session
tasks usingDomainSession
(fastest tests, domain layer coverage)session
tasks usingHttpSession
(slower tests, http + domain layer coverage)dom
tasks usingDomainSession
(slower tests, UI + domain layer coverage)dom
tasks usingHttpSession
(slowest tests, UI + http + domain layer coverage)
In the example we use world parameters to control how to interact with the system and how the system is assembled.
Credits
This library is inspired by the Screenplay Pattern. This list is a chronological order of events, implementations and writings related to the evolution of the pattern.
- 2007: In praise of abstraction - talk by Kevin Lawrence.
- 2007: Antony Marcano demonstrates the Journey Pattern at the Agile Alliance Functional Test Tools workshop.
- 2009: Antony Marcano and Andy Palmer showed Aslak Hellesøy the pattern at Agile 2009 in Chicago.
- 2012: Screenplay4j - the first public implementation by Antony Marcano and Andy Palmer.
- 2012: User Centred Scenarios: Describing capabilities, not solutions - talk by Andy Palmer and Antony Marcano where "millisecond acceptance" tests are described towards the end.
- 2015: John Ferguson Smart and Jan Molak, along with Andy and Antony, added native support for the Screenplay pattern to Serenity BDD for Web and API testing, 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: Screenplays and Journeys, Not Page Objects - blog post by Jeff Nyman
- 2016: Screenplay Pattern as described by Jan Molak.
- 2016: Serenity/JS - the original JavaScript/TypeScript implementation of the Screenplay Pattern by Jan Molak.
- 2017: Nat Pryce explored using the Screenplay Pattern to write tests that run in milliseconds - https://speakerdeck.com/npryce/having-our-cake-and-eating-it-1
- 2019: ScreenPy - a Python implementation of the Screenplay pattern inspired by the Serenity BDD implementation
- 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: BDD in Action, 2nd Edition (by John Ferguson Smart with Jan Molak) includes a full chapter and many examples of the Screenplay pattern in Java and Typescript