Serenity BDD and the Screenplay Pattern: Designing SOLID Actors
In this article, Jan Molak answers some of the most popular questions related to designing Screenplay Actors and making them SOLID.
Join the DZone community and get the full member experience.
Join For FreeAs the Screenplay Pattern grows in popularity, it’s natural that more questions and more sophisticated use cases emerge in the wild.
In this article, I’d like to answer some of the most popular ones related to designing Screenplay Actors and making them SOLID.
The Role of an Actor
Here’s the first question:
I have a system where users can have different roles (e.g. author, editor, admin) and can see different sections of the webpage. Would these roles each have dedicated Actors? Or would I rather create Abilities that can reach those sections and assign them to generic Actors as needed?
In the Screenplay Pattern, an Actor represents either a person or an external system verifying and interacting with the system they’re testing.
The reason why we tend to model our Actors as separate entities and associate them with distinct personas is also the reason why Actors have names. Let’s look into this in more detail.
Consider the following example:
Actor aurelia = Actor.named("Aurelia"); // Aurelia, the Author
Actor edward = Actor.named("Ed"); // Ed, the Editor
Actor adam = Actor.named("Adam"); // Adam, the Administrator
Here we’re giving our Actors names that sound similar to the name of the Role they perform. This makes it easier not only to distinguish one Actor from another but more importantly to spot logical errors in the tests: Hey, why is Aurelia the Author touching the admin panel?
Of course, giving an Actor a name associated with their persona serves indicative purposes only. It’s not a hard constraint that could be verified at either compile or runtime, but neither it’s intended to be.
Having said that, if we needed to be a bit more strict about what Actors can and cannot do in our acceptance tests, we could model those constraints using Abilities. This leads us onto the second question.
The Responsibilities of an Actor
How would I store username and password of an Actor? I’d like to be able to log in and check a login state later, i.e. “Logged in as: username”?
Before we answer the question of how to store additional data on an Actor, it’s worth asking: do we actually need to do this?
There are several ways we could go about solving the authentication problem mentioned in the question. Let’s talk about the most popular ones and start with discussing an inheritance-based implementation first, talk about its pros and cons and then look at a composition-based approach and see what it has to offer.
Inheritance-Based Approach
To solve the problem using inheritance we can extend and build on the Screenplay Actor class, adding any additional methods and data we need:
class RegisteredUser extends net.serenitybdd.screenplay.Actor {
private final String username;
private final String password;
public RegisteredUser(
String name,
String username,
String password)
{
super(name);
this.username = username;
this.password = password;
}
public String username() {
return username;
}
public String password() {
return password;
}
}
With such RegisteredUser class in place, we could instantiate Aurelia like this...
RegisteredUser aurelia = new RegisteredUser(
"Aurelia", "aurelia@example.com", "P@ssw0rd");
...and ask her to log into the system...
Actor.named("Aurelia")
.whoCan(BrowseTheWeb.with(browser))
.whoCan(UploadFiles.to(ftpServerUrl));
using the below LogIn Task, which retrieves the username and password stored on the Actor and ...this passes them onto the Enter Interaction, which enters them into the LoginForm fields:
public class LogIn implements net.serenitybdd.screenplay.Task {
public static LogIn withCredentials() {
return instrumented(LogIn.class);
}
@Override
@Step("Logs in as: {0}")
public void <T extends Actor> performAs(T user) {
user.attemptsTo(
Enter.theValue(registered(user).username())
.into(LoginForm.Username),
Enter.theValue(registered(user).password())
.into(LoginForm.Password),
Click.on(LoginForm.LogInButton)
);
}
private RegisteredUser registered(Actor actor) {
return (RegisteredUser) actor;
}
}
The advantage of this approach is that it’s trivial to implement and reason about and if you only need to make the actor know about one additional piece of data, this strategy might be enough.
However, if on top of being able to authenticate using a web browser, Aurelia also needed to know how to log in to an FTP server, an email server, authenticate with a REST API, and simulate her finger touching a fingerprint sensor on her mobile phone — we would have ended up with a seriously bloated implementation of our RegisteredUser class.
We would have also violated the Interface Segregation Principle.
Could we do better? Sure we could.
Composition-Based Approach
Let’s talk about composition.
You might remember that every Actor in the Screenplay Pattern needs Abilities to enable them to perform their Tasks and achieve their Goals.
Those Abilities enable the Actor to perform Interactions and interact with the System under test:
Domain model of the Screenplay Pattern
The Screenplay Pattern is very flexible and not tied to any particular interface. This means that the Abilities could enable an Actor to “browse the Web,” “interact with a REST API,” “send emails,” “upload files to an FTP server,” and so on.
Also, the purpose of separating Abilities such as “browsing the web” from Tasks like “logging in” is to introduce a translation layer and bridge the context of the business domain: “Aurelia attempts to log in” and the implementation domain: “The browser sends a POST request with Aurelia’s username and password.”
All this means that what Aurelia can do could be defined using code similar to this example:
Actor.named("Aurelia")
.whoCan(BrowseTheWeb.with(browser))
.whoCan(UploadFiles.to(ftpServerUrl));
We could take this idea further, though.
We could think that being able to Authenticate is an Ability of an Actor, something they can do rather than who they are:
Actor.named("Aurelia")
.whoCan(Authenticate.with("aurelia@example.com", "P@ssw0rd"))
.whoCan(BrowseTheWeb.with(browser))
.whoCan(UploadFiles.to(ftpServerUrl));
This way we no longer need the custom Actor class. Instead, we’d store the username and password within an instance of the Ability to Authenticate:
public class Authenticate
implements net.serenitybdd.screenplay.Ability
{
private final String username;
private final String password;
// instantiates the Ability and enables fluent DSL
public static Authenticate with(
String username,
String password)
{
return new Authenticate(username, password);
}
// retrieves the Ability from an Actor within the Interaction
public static Authenticate as(Actor actor) {
// complain if someone's asking the impossible
if (actor.abilityTo(Authenticate.class) == null) {
throw new CannotAuthenticateException(actor.getName());
}
return actor.abilityTo(Authenticate.class);
}
public String username() {
return this.username;
}
public String password() {
return this.password;
}
private Authenticate(String username, String password){
this.username = username;
this.password = password;
}
}
Same as with the inheritance-based example, we’ll still need a Task to log in, but this time it will look a bit different:
public class LogIn implements net.serenitybdd.screenplay.Task {
public static LogIn withCredentials() {
return instrumented(LogIn.class);
}
@Override
@Step("Logs in as: {0}")
public void <T extends Actor> performAs(T actor) {
actor.attemptsTo(
Enter.theValue(authenticated(actor).username())
.into(LoginForm.Username),
Enter.theValue(authenticated(actor).password())
.into(LoginForm.Password),
Click.on(LogInButton)
);
}
private Authenticate authenticated(Actor actor) {
return Authenticate.as(actor);
}
}
A call to the authenticated (Actor) method retrieves the Actor’s ability to Authenticate, and with it, the username and password needed for Aurelia to log in.
The advantage of this approach is that it complies with the SOLID principles:
- Single Responsibility Principle: the Actor, Authenticate, and LogIn classes each have a single responsibility.
- Open/Closed Principle: if we needed to authenticate using a different mechanism, say OpenId, we wouldn’t have to change the custom Actor class as in the inheritance-based example. Instead, we’d create an AuthenticateWithOpenId Ability and a Task to LogInWithOpenId.
- Liskov Substitution Principle: keeping the Abilities separate from Interactions allows us to not only bridge the bound domain context in an elegant way but also to substitute one authentication strategy for another
- Interface Segregation Principle: we didn’t have to pollute the Actor class with custom public methods, not related to their primary responsibility of executing Tasks.
- Dependency Inversion Principle: we depend on interfaces such as “Ability,” “Task,” and “Interaction” rather than their concrete implementations. That’s why we can easily swap them in and out.
In Closing
Design, including software design, is an art of trade-offs, and most real world problems have more than one solution.
As you’ve seen, the inheritance-based approach to solving the problem of authentication and storing user’s credentials on a custom Actor class works well for simple use cases and often might be all you need.
The composition-based approach focuses on designing what Actors can do rather than what they are. This helps to keep the code clean and the classes small when you need to tackle the more sophisticated problems. Bear in mind that for the simple ones, this design might result in a more complex object model.
If you have a chance, try to experiment with both and share your experience in the comments below. If you have more questions, get in touch!
Published at DZone with permission of Jan Molak, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments