Once you’ve decided to invest in test automation, sooner or later you’ll begin to realise that you need to care about the maintainability of that test automation code just as much as you do about the implementation code itself.
For acceptance tests this problem is particularly acute: driving the application from its outside edges can involve connecting to APIs, databases, web UIs etc., and this code can quickly get out of hand. Having tried several different approaches myself over the years, I’ve come to believe that the Screenplay pattern is the best technique we have today for keeping this code lean and fun to work with.
But it doesn’t get the attention it deserves.
If you’ve never heard of Screenplay, or have heard of it but never tried it, this tutorial is for you. It will be technical, with a good deal of code. The code will be in JavaScript, but I hope you’ll be able to follow it if you’re familiar with coding in any other language too.
Myths about Screenplay
First I want to start by addressing a couple of popular misconceptions about the pattern, and give you a quick view of the fundamentals, to help frame the rest of what’s to come.
Myth #1: Screenplay is for testing screens
Many people I’ve talked to about Screenplay have heard the word "screen", or read the blog posts contrasting it with the page object pattern, and assumed it must be only for UI testing. That’s not the case.
The folks behind the screenplay pattern love a metaphor.
The metaphor here is not about screens from the user interface of your app, no! The metaphor is that each of your scenarios is like a little stage-play, with actors performing actions.
In fact, as we’ll see, the whole idea is to get away from thinking about interaction details, and focus on behaviour and intent.
In retrospect, maybe something like Playwright might have made a better name for this pattern to avoid any confusion caused by conflated interpretations of the word "screen". Anyway, it’s too late for that now.
So let’s be totally clear. The Screenplay pattern does not have anything to say about whether you test through your UI or not. In fact, Screenplay can help free you up from this hideous bind, and give you more choices about where to connect to your app, and keep your code better organised.
Myth #2: Screenplay is over-complicated
I’ll admit that this was my reaction at first. Even though I had a strong feeling there was something useful in this pattern, I found all the early examples I read to be quite hard to fathom, with a lot of new concepts to understand. It all made me feel a bit stupid.
Having got my head around it now, I can tell you for sure that there’s a super-simple core idea here. Essentially, Screenplay is just the [command pattern](https://en.wikipedia.org/wiki/Command_pattern) applied in the specific context of organising test automation code.
In fact, I bet that if you’ve worked on a test automation suite of any reasonable size, you’ll know exactly what over-complicated feels like. Massive classes heaving with too many methods. Awkward hierarchies of objects to try and avoid duplication. A maze of different files. This pattern is a way out of that.
If I’ve done my job right then, by the end of this series, you’ll see that it’s really not that complicated at all.
The fundamentals of Screenplay
The fundamental element of the Screenplay pattern is an Interaction. Instead of having the code that interacts with our app littered around in helper methods or page objects, we separate and encapsulate each tiny granule of work as an individual object, each with the same interface: a single method that allows us to run that action against our app.
For example, you might have an Interaction like InsertInto
which knows how to insert a record into an SQL database table. Or you might have an Interaction like WriteTo
that can write to a text file on disk. Or an Interaction like FillIn
which can put text into a field in a browser. Or an Interaction like PostJson
that sends a payload to an API endpoint.
Then, we express the behaviour we want in our tests by creating lists of these Interaction objects that we want to execute, like:
attemptTo(
new InsertInto({
table: "Accounts",
data: { name: "Dave", password: "secret", company: "ACME" }
}),
new FillIn({
field: "Username",
with: "Dave"
}),
new FillIn({
field: "Password",
with: "secret"
})
new ClickOn({ button: "Login" })
)
This set of interactions creates a record in a database to represent a user called Dave
, then fills out a web form to log him in.
We can create factory DSLs to make this code read better:
attemptTo(
InsertInto.table("Accounts").data({ name: "Dave", password: "secret", company: "ACME" }),
FillIn.field("Username").with("Dave"),
FillIn.field("Password").with("secret"),
ClickOn.button("Login" )
)
The sweet thing about breaking down our automation into these tiny pieces is that it allows us to compose these little Interactions into bigger chunks (called Tasks) that make sense in our particular problem domain:
attemptTo(
CreateAccount.for("Dave").withPassword("secret"),
Login.as("Dave").withPassword("secret")
)
The basic shift in thinking that you’ll need to grasp in order to understand the Screenplay pattern is this: all of your automation behaviour is going to be organised into little elemental pieces instead of being in methods on helper classes.
Screenplay doesn’t have to be something you can only introduce into your next greenfield project. We’ve successfully introduced Screenplay side-by-side with existing automation code, gradually refactoring towards it.
Over the course of this tutorial, we’ll refactor take a typical Cucumber codebase and refactor it towards this pattern, extracting and develop our own little Screenplay library as we go. In doing this, you’ll get a firm handle on how to apply the pattern yourself.
What’s wrong with helpers?
In the first post in this series we introduced the concept of the Screenplay pattern and busted a couple of popular myths about it. Now it’s time to start digging into some code to give us a real example to demonstrate the pattern on.
The problem with writing this kind of tutorial, always, is finding the right balance between an example that’s so complicated it gets in the way of your understanding the thing we’re actually trying to learn about, and one that’s so simplistic that the need for any kind of software design seems superfluous. If you’ll forgive me, we’ll err towards the simplistic here, and I’ll trust that you’ve seen enough complex code in the wild to recognise the need for some design work.
Time for an example
Turn your imagination up to max and pretend we’re building a web-based app that needs people to be able to sign up. This sign up feature is going to be the big differentiator to help us stand out in the market, I can feel it.
Sign-up is a two-step process, as described in this feature file:
Feature: Sign up
New accounts need to confirm their email to activate
their account.
Scenario: Successful sign-up
Given Tanya has created an account
When Tanya activates her account
Then Tanya should be authenticated
Scenario: Try to sign in without activating account
Given Bob has created an account
When Bob tries to sign in
Then Bob should not be authenticated
And Bob should see an error telling him to activate the account
So far so good. We have one happy path scenario for Tanya creating then activating her account, and being able to sign in. We have another that illustrates what happens if hapless Bob tries to sign in before he’s got around to activating the account.
Now let’s drop down a layer and have a look at the step definitions we’ve written that bring these scenarios to life our JavaScript project:
const { Given, When, Then } = require('cucumber')
const { assertThat, is, not, matchesPattern } = require('hamjest')
Given('{word} has created an account', function (name) {
this.createAccount({ name })
})
When('{word} tries to sign in', function (name) {
this.signIn({ name })
})
When('{word} activates his/her account', function (name) {
this.activateAccount({ name })
})
Then('{word} should not be authenticated', function (name) {
assertThat(this.isAuthenticated({ name }), is(not(true)))
})
Then('{word} should be authenticated', function (name) {
assertThat(this.isAuthenticated({ name }), is(true))
})
Then('{word} should see an error telling him/her to activate the account', function (name) {
assertThat(
this.authenticationError({ name }),
matchesPattern(/activate your account/)
)
})
There’s a couple of things going on here, so let’s pick them apart.
We can see that each step definition uses the cucumber expression syntax, avoiding the need for ugly regular expressions, and instead capturing the name of the user with the built in {word}
parameter type. Partly, I just wanted to show off this new feature, but it’s also going to come in handy later.
Next, notice that each of the step definition functions is a one-liner, delegating to a method on this
. Delegating the automation work to helper methods allows us to keep a separation of concerns between these two layers:
-
Step Definitions translate from plain English in the scenarios into code.
-
Helper, or driver methods actually interact with the app.
Working this way, we build up a library of re-usable driver code that does the actual leg-work of automating our app. Gradually, we develop an API (some people even call it a DSL) for driving our application from our tests.
Introducing this separation also allows us the flexibility to swap in different implementations of the driver API, when we want to connect our automation at different layers of the app. If we want to, we can have a SeleniumDriver that uses a browser (perhaps via page objects), and a DomainDriver that does the same things directly against the domain model.
But I digress.
Where do the helper methods live?
In cucumber-js projects, this
in the context of a step definition function is the World, an object that is created by Cucumber for the duration of the scenario, which we can add custom methods and properties to. Ruby’s flavour of Cucumber offers the same concept. In Cucumber-JVM and SpecFlow projects you have to inject your own objects that contain your helpers, but the principle is the same.
We won’t dig any deeper into our automation stack right now. Let’s just trust that those helper methods are there, doing what they say they do.
A new requirement
As they tend to do from time to time, our product owner would like this app of ours to do something new. She has an idea that our app will have projects that users with existing accounts can create. I don’t know where she gets these wild ideas.
Here’s the feature file:
Feature: Create project
Users can create projects, only visible to themselves
Scenario: Create one project
Given Sue has signed up
When Sue creates a project
Then Sue should see the project
Scenario: Try to see someone else's project
Given Sue has signed up
And Bob has signed up
When Sue creates a project
Then Bob should not see any projects
We’ve introduced a handful of new steps here. Let’s look at how these are defined:
const { Given, When, Then } = require('cucumber')
const { assertThat, is, not, matchesPattern, hasItem, isEmpty } = require('hamjest')
Given('{word} has created an account', function (name) {
this.createAccount({ name })
})
When('{word} tries to sign in', function (name) {
this.signIn({ name })
})
When('{word} activates his/her account', function (name) {
this.activateAccount({ name })
})
Given('{word} has signed up', function (name) {
this.createAccount({ name })
this.activateAccount({ name })
})
When('{word} (tries to )create(s) a project', function (name) {
this.createProject({ name, project: { name: 'a-project' }})
})
Then('{word} should see the project', function (name) {
assertThat(
this.getProjects({ name }),
hasItem({ name: 'a-project' })
)
})
Then('{word} should not see any projects', function (name) {
assertThat(
this.getProjects({ name }),
isEmpty()
)
})
Then('{word} should not be authenticated', function (name) {
assertThat(this.isAuthenticated({ name }), is(not(true)))
})
Then('{word} should be authenticated', function (name) {
assertThat(this.isAuthenticated({ name }), is(true))
})
Then('{word} should see an error telling him/her to activate the account', function (name) {
assertThat(
this.authenticationError({ name }),
matchesPattern(/activate your account/)
)
})
This all seems quite straightforward in our simple example, but if we’re really sensitive to them, we might notice a couple of issues that could cause concern as the codebase grows.
First, we have duplication of the project name, a-project
. We’ve made the sensible choice to push this detail down out of the scenarios to make them more readable (avoiding incidental details), but we’re now left with the problem of needing that information in two places in our code. One solution might be to stash the project name on the World, like this:
When('{word} (tries to )create(s) a project', function (name) {
this.projectName = 'a-project'
this.createProject({ name, project: { name: this.projectName }})
})
Then('{word} should see the project', function (name) {
assertThat(
this.getProjects({ name }),
hasItem({ name: this.projectName })
)
})
If you use Cucumber-Ruby you’ve probably done this kind of thing using an instance variable. SpecFlow or Cucumber-JVM practitioners maybe have used a ScenarioContext object. Like using helper methods, this is common practice in the Cucumber community. While this will work OK for now, what we’re starting to see is that the World object is just getting more and more complicated.
It’s becoming a God object.
Well-designed objects have the property of cohesion. That means the methods on that object all have a reason to be together, such as sharing the same internal state. God objects don’t have this property. They’re just a grab-bag of methods, properties and state that have no reason to be on the same class other than for a lack of a better place to put them.
Another niggling concern is that we’ve broken our idiom of having one-liner step definitions. We’re introduced the concept of "signing up" which rolls together the two granular actions of creating an account and then activating it.
At this stage, a two-line step definition is not a big deal, but it’s the wrong direction of travel. As we build up a more rich and interesting system we’ll have more and more of these abstractions. We could push this down into a signUp
helper method on the world, but this doesn’t actually tackle the complexity, it just pushes it away somewhere we can’t see it so easily.
Cracks are appearing
Let’s take a step back for a minute and review what we do and don’t like about this code so far.
On the up-side:
-
The scenarios read really nicely. We’re not being forced to put details into the scenarios for the sake of what’s easy to code.
-
We’ve got a good separation of concerns, with the step definitions staying simple, delegating the work of actually driving the application to helper methods.
-
The language in the step definition code is consistent with the language of the Gherkin steps. For example, we see calls to signUp and activateAccount methods rather than any details about click or fillIn. There’s no translation happening in the step defintions, so we can trust them.
But we’re concerned that:
-
we have duplication of hard-coded details like the name of the project, when we need to share implicit context (i.e. "the project") between steps.
-
adding more and more helper methods onto the World means that class will just grow and grow, and it will lack cohesion.
At the moment, in this tiny example codebase, these concerns are only hairline cracks; but we know what happens as our codebase evolves: small problems turn into big problems and, before you know it, you’re stuck in a codebase you hate.
What about Page Objects?
What if we were to use page objects? Could they help us?
Certainly, having different objects to represent the sign up form, the login form, and the new project page, would avoid us lumping all our automation code into one World object. However, this doesn’t stop the bloat. Page objects that model every button and interaction point on a page can become huge and unwieldy.
A more insidious problem is that page objects are based around the UI. This means there would have to be a leap in translation in our step defintions: we’d jump from a problem-domain concept like create a project to the nitty-gritty interactions with the app that will cause this action to happen. This isn’t such a clean separation of concerns, and distracts us from focussing on the behaviour and intent of our users. If we’re not careful, these detailed interactions can start to leak out into the Gherkin.
So now we have a sense of some of the problems with the typical approaches to organising acceptance test automation code.
Next we’ll have a look at what Screenplay could offer us to solve these problems, and start using it in our example application.
Refactoring to Screenplay
Now we understand the need for a new kind of pattern for organising our test automation code, we’re going to work with this little codebase to refactor it towards the Screenplay pattern. By taking the existing code and shifting it, step-by-step, towards the pattern, my hope is that you’ll see how you could do this on your own code.
When refactoring to Screenplay, I’ve found it’s best to start with the smallest elements of behaviour first. The leaf-nodes, if you will. So we need to look for a helper method that just does one thing.
Let’s pick the createAccount
helper method in the DomainDriver
:
createAccount({ name }) {
this.app.accounts[name] = new Account({ name })
}
We call this method twice from our steps. First, we use it for creating accounts:
Given('{word} has created an account', function (name) {
this.createAccount({ name })
})
Then, later, we use it as part of signing up:
Given('{word} has signed up', function (name) {
this.createAccount({ name })
this.activateAccount({ name })
})
We’ll work with the first instance to turn this into a Screenplay interaction. We’ll come back to the second one later.
Let’s inline the first instance so that…
Given('{word} has created an account', function (name) {
this.createAccount({ name })
})
becomes…
Given('{word} has created an account', function (name) {
this.app.accounts[name] = new Account({ name })
})
Now we have the code back up in the step definition, we can re-shape it into a Screenplay style.
Extract Interaction
We’ll put the code that does this work into an arrow function expression which will become our first Interaction. The interaction takes all the dependencies and information it will need to do its work. In this case, it needs a reference to the app
it will use to fetch the accounts, and the name
of the actor.
To complete this first refactoring, we call the interaction function, passing the name from the step definition and a reference to the app
.
Given('{word} has created an account', function (name) {
const interaction = ({ name, app }) => app.accounts[name] = new Account({ name })
interaction({ name, app: this.app })
})
Now the code does the same as it did before. We can run npm test
at this point to check all our scenarios are still passing.
Actors perform Interactions using Abilities
Back in the first part of this series, we talked about the metaphor for Screenplay being actors on a stage.
Instead of calling our interaction directly from our step definition, we’re going to create an Actor
that will attempt the interaction.
In the metaphor, the actor is said to have abilities: the things it needs to be able to perform the actions it’s given. In practice, these are the dependencies that the interaction functions will expect to be passed, like a browser
or a database
connection. We’ll take these as we construct the Actor
.
We’ll add a method that attempts to perform an interaction, by simply passing these abilities to the interaction and calling it:
class Actor {
constructor(abilities) {
this.abilities = abilities
}
attemptsTo(interaction) {
interaction(this.abilities)
}
}
Perfect. Now, the actor has everything it needs to be able to attempt to perform an action.
Here’s how we use the shiny new Actor
in our step definition:
Given('{word} has created an account', function (name) {
const interaction = ({ name, app }) => app.accounts[name] = new Account({ name })
const actor = new Actor({ name, app: this.app })
actor.attemptsTo(interaction)
})
To construct the actor, we’re creating a bare JavaScript object representing the actor’s abilities to pass to the constructor; in this case their name
and whatever attributes are on this
.
Then we call attemptsTo
on the actor, passing it our interaction.
Again, we can run npm test
at this point to check all our scenarios are still passing.
Using a custom parameter type
Since the Screenplay pattern centres around these Actors, we don’t want to have to keep constructing them in our step definitions. Fortunately, Cucumber gives us custom parameter types that allows us to transform the text Sue
, Tanya
or Bob
into an instance of Actor
that represents them.
Here’s how we do that:
const { defineParameterType } = require('cucumber')
defineParameterType({
name: 'actor',
regexp: /(Sue|Tanya|Bob)/,
transformer: function(name) {
return new Actor({ name, app: this.app })
}
})
Now we can ask for an instance of Actor
in our step definition, by using the new custom parameter type:
Given('{actor} has created an account', function (actor) {
const interaction = ({ name, app }) => app.accounts[name] = new Account({ name })
actor.attemptsTo(interaction)
})
Our code is getting neater, but we’re not done yet.
Naming our Interaction
An idiom that Screenplay’s original authors used was to adopt a fluent interface for creating interactions. Let’s rename our interaction in this style, calling it CreateAccount.forThemselves
. We do this by creating a plain JavaScript object, CreateAccount
, with a property forThemselves
that returns the interaction function expression:
const CreateAccount = {
forThemselves:
({ name, app }) => app.accounts[name] = new Account({ name })
}
Now we can use that in our step definition:
Given('{actor} has created an account', function (actor) {
actor.attemptsTo(CreateAccount.forThemselves)
})
In fact, since we’re no longer using this
, we can now use an arrow function for our step definition:
Given('{actor} has created an account',
actor => actor.attemptsTo(CreateAccount.forThemselves)
)
Wrapping up
Hopefully you can see things starting to fall into place. We’re delegating the work in our step definitions off to actors and interactions. The actors have abilities that enable them to perform the interactions, but they’re completely decoupled from those interactions themselves. Each interaction is a separate JavaScript function that takes the abilities and does something to the system.
This decoupling enables this pattern to scale really well. We can add new interactions easily, without creating extra dependencies or bloated helper classes.
All of this leaves us with step defintions that are much more readable than before, with a clear mapping from the plain English in our Gherkin steps to the code that carries out the step.
The real beauty of the actor-interactions model is that interactions are composable: we can build up more interesting actions out of fine-grained interactions, like putting lego pieces together. That’s what we’ll look at next.
Composing Tasks from Interactions
Previously, we extracted our a simple implementation of the screenplay pattern from some existing Cucumber automation code, turning this:
Given('{word} has created an account', function (name) {
this.createAccount({ name })
})
…into this:
Given('{actor} has created an account',
actor => actor.attemptsTo(CreateAccount.forThemselves)
)
We’re going to continue this refactoring, looking at how we can compose granular interactions into tasks that model higher-level concepts in our problem domain.
Multiple interactions
The other place where we were using the createAccount
method is in this step definition:
Given('{word} has signed up', function (name) {
this.createAccount({ name })
this.activateAccount({ name })
})
In this case, creating an account is part of a bigger goal, or task: to sign up. First we create the account, then we activate it. Together, these two steps achieve that task.
We can use the same steps as last time to create a new interaction, Activate.theirAccount
:
const Activate = {
theirAccount:
({ name, app }) => {
app.getAccount(name).activate()
app.authenticate({ name })
}
}
We can change the step definition above to use the two new interactions:
Given('{actor} has signed up', function (actor) {
actor.attemptsTo(CreateAccount.forThemselves)
actor.attemptsTo(Activate.theirAccount)
})
This is OK, but it contains some duplication that makes it less readable than it could be. What if we could do this instead?
Given('{actor} has signed up',
actor => actor.attemptsTo(
CreateAccount.forThemselves,
Activate.theirAccount
)
)
Good news, we can! We just need to tweak our Actor
to accept an array of interations:
class Actor {
constructor(abilities) {
this.abilities = abilities
}
attemptsTo(...interactions) {
for(const interaction of interactions)
interaction(this.abilities)
}
}
Now we can pass one or more interactions to our Actor and it will attempt each of them in turn.
Composing a task
Let’s look at how we could model the task of signing up in the Screenflow style.
In Screenplay, tasks are distinct from interactions because they do not interact directly with the system under test: intead, they attempt interactions, or other tasks.
In order for our actor to be able to perform tasks, we need to give it one new, special ability:
class Actor {
constructor(abilities) {
this.abilities = {
...abilities,
attemptsTo: this.attemptsTo.bind(this)
}
}
attemptsTo(...actions) {
for(const action of actions)
action(this.abilities)
}
}
We’ve added the Actor’s attemptsTo
method to the set of abilities that will be passed to the tasks or interations being attempted. To reflect the fact that the parameters in the attemptsTo
method could be tasks or interactions, we’re renamed them to the more general actions
.
This enables us to build a new signUp
task:
const signUp =
({ attemptsTo }) => attemptsTo(
CreateAccount.forThemselves,
Activate.theirAccount
)
You might notice we haven’t used a capital letter to name this task. There’s nothing special about this, we just decided not to namespace this function in an object yet because we can’t imagine having any other variants of it. Perhaps someday we’ll want to be able to SignUp.usingInvalidCredentials
but we can always refactor when the time comes.
Now we can use the task in our step definition:
Given('{actor} has signed up',
actor => actor.attemptsTo(signUp)
)
Nice. Again, we can see a clear mapping from the plain English in our scenario to the code we’re running in our automation layer.
The signUp
task has a dependency on the two interactions, but it sits as an independent function, intead of adding more bloat to a helper class. As our codebase grows, we’ll see lots of these tiny little interaction functions growing, with tasks growing around them. Because it’s made of smaller, decoupled pieces, our automation code will be easier to change.
The separation between tasks and interactions gives us the potential to choose different sets of interactions to use with your tasks, depending on whether you want to interact with your app via the UI, or at a lower level. We’ll look at that in a later post.