Serenity BDD Screenplay Pattern: SOLID Browser Test Architecture

Serenity BDD Screenplay Pattern: SOLID Browser Test Architecture

The Screenplay pattern is an alternative to the Page Object pattern for organizing browser tests. It replaces pages-as-objects with actors who perform tasks using abilities. The goal is tests that read like requirements and code that follows SOLID principles — particularly the Single Responsibility Principle. Serenity BDD has first-class Screenplay support.

Core Concepts

Actor: Represents a user (or system) interacting with the application. Actors have abilities (like browsing the web) and perform tasks.

Ability: What an actor can do — BrowseTheWeb, CallAnApi, InteractWithFiles.

Task: A high-level action composed of lower-level interactions — "Add a product to cart", "Complete checkout".

Interaction: A low-level action — Click, Enter, NavigateTo.

Question: A query about the current state — "What is the page title?", "Is the error message visible?"

Setup

Dependencies (Maven):

<dependency>
    <groupId>net.serenity-bdd</groupId>
    <artifactId>serenity-screenplay</artifactId>
    <version>4.1.20</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>net.serenity-bdd</groupId>
    <artifactId>serenity-screenplay-webdriver</artifactId>
    <version>4.1.20</version>
    <scope>test</scope>
</dependency>

Defining Actors and Abilities

import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.abilities.BrowseTheWeb;

// In test setup
Actor alice = Actor.named("Alice");
alice.can(BrowseTheWeb.with(driver));

// Or with Serenity-managed driver
OnStage.setTheStage(Cast.ofStandardActors());
Actor alice = OnStage.theActorCalled("Alice");

Tasks

Tasks represent business-level operations. They implement Performable:

import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.Tasks;
import net.serenitybdd.screenplay.actions.Click;
import net.serenitybdd.screenplay.actions.Enter;
import net.serenitybdd.screenplay.actions.Open;

public class Login implements Task {

    private final String email;
    private final String password;

    public Login(String email, String password) {
        this.email = email;
        this.password = password;
    }

    // Factory method — makes test code read naturally
    public static Login withCredentials(String email, String password) {
        return Tasks.instrumented(Login.class, email, password);
    }

    @Override
    @Step("{0} logs in as #email")
    public <T extends Actor> void performAs(T actor) {
        actor.attemptsTo(
            Open.url("https://example.com/login"),
            Enter.theValue(email).into(LoginPage.EMAIL_FIELD),
            Enter.theValue(password).into(LoginPage.PASSWORD_FIELD),
            Click.on(LoginPage.SIGN_IN_BUTTON)
        );
    }
}

Using Tasks in Tests

@Test
void alice_can_access_dashboard_after_login() {
    Actor alice = OnStage.theActorCalled("Alice");

    alice.attemptsTo(
        Login.withCredentials("alice@example.com", "password123")
    );

    alice.should(
        seeThat(CurrentPage.title(), containsString("Dashboard"))
    );
}

The test reads: "Alice attempts to log in with credentials. Alice should see that the current page title contains Dashboard."

Target — UI Element References

Target replaces page object element fields. It's a named, located UI element:

import net.serenitybdd.screenplay.targets.Target;
import org.openqa.selenium.By;

public class LoginPage {
    public static final Target EMAIL_FIELD =
        Target.the("email field").located(By.name("email"));

    public static final Target PASSWORD_FIELD =
        Target.the("password field").located(By.name("password"));

    public static final Target SIGN_IN_BUTTON =
        Target.the("sign in button").located(By.cssSelector("button[type=submit]"));

    public static final Target ERROR_MESSAGE =
        Target.the("error message").located(By.cssSelector("div.error"));
}

Targets are named for reporting purposes — "email field" appears in the Serenity report rather than a CSS selector.

Questions

Questions query application state and return values for assertions:

import net.serenitybdd.screenplay.Question;
import net.serenitybdd.screenplay.questions.Text;
import net.serenitybdd.screenplay.questions.Visibility;

// Built-in questions
Text.of(LoginPage.ERROR_MESSAGE)     // returns the text content
Visibility.of(LoginPage.ERROR_MESSAGE)  // returns boolean

// Custom question
public class CurrentPageUrl implements Question<String> {
    @Override
    public String answeredBy(Actor actor) {
        return BrowseTheWeb.as(actor).getCurrentUrl();
    }

    public static Question<String> value() {
        return new CurrentPageUrl();
    }
}

Using Questions in Assertions

alice.should(
    seeThat(Text.of(LoginPage.ERROR_MESSAGE), equalTo("Invalid credentials")),
    seeThat(Visibility.of(LoginPage.ERROR_MESSAGE), is(true))
);

alice.should(
    seeThat(CurrentPageUrl.value(), containsString("/login"))
);

Composing Tasks

Tasks compose — higher-level tasks call lower-level ones:

public class CompletePurchase implements Task {

    private final String productName;

    public static CompletePurchase of(String productName) {
        return Tasks.instrumented(CompletePurchase.class, productName);
    }

    @Override
    @Step("{0} completes purchase of #productName")
    public <T extends Actor> void performAs(T actor) {
        actor.attemptsTo(
            AddToCart.product(productName),      // another Task
            ProceedToCheckout.now(),              // another Task
            FillShippingDetails.withDefaults(),   // another Task
            PlaceOrder.now()                      // another Task
        );
    }
}

Each composed task appears as a nested step in the Serenity report, giving full visibility into the execution hierarchy.

Screenplay vs Page Objects: Key Difference

Page Object approach:

loginPage.enterEmail("alice@example.com");
loginPage.enterPassword("password123");
loginPage.clickSignIn();
dashboardPage.verifyTitle();

Pages own actions. If you need to compose multiple pages, you call multiple page objects in sequence.

Screenplay approach:

alice.attemptsTo(Login.withCredentials("alice@example.com", "password123"));
alice.should(seeThat(DashboardPage.title(), containsString("Dashboard")));

The actor owns the action sequence. Tasks are composable objects, not tied to any page.

The practical difference shows at scale: Screenplay tests for complex flows (multi-step checkout, wizard forms) stay readable because tasks compose cleanly. Page object tests for the same flows become sequences of page object method calls that can be hard to follow.

When Screenplay Makes Sense

  • Complex user journeys: Multi-step flows where composability matters
  • Multiple actor types: Tests involving two users interacting (e.g., admin approves, user sees result)
  • Teams that practice BDD: Screenplay's language maps cleanly to Cucumber steps
  • SOLID purists: Each Task has one responsibility and is easy to test in isolation

For simple CRUD apps with straightforward flows, Page Objects are less ceremony for the same outcome. Screenplay pays off when test complexity grows.

Summary

The Screenplay pattern organizes browser tests around who does what, not which page they're on. Actors perform tasks using abilities, questions retrieve state. The Serenity HTML report reflects this structure — step names come from task and action class names, producing readable execution traces without extra annotation work.

The pattern requires more classes than Page Objects. The payoff is tests that read like requirements and a codebase where each class has a clear, single responsibility.

Read more

Start now free