paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

The Screenplay Pattern in UI Automation: SOLID Principles, Design Patterns, and Real-World Practice

How actor-centric test design - grounded in SOLID principles - produces automation suites that survive changing UIs, scale with teams, and actually get maintained.

Introduction

Most UI automation suites start clean and grow ugly. A team writes a handful of Page Object classes, ships them, and then - six months later - finds itself maintaining 400-line God Objects stuffed with every possible interaction a screen can support. Tests become fragile. Changes to the UI require touching a dozen files. New contributors struggle to understand what a test is actually asserting. The automation layer, meant to give teams confidence, becomes a liability.

The Screenplay Pattern is a response to exactly that trajectory. Introduced by Antony Marcano and further developed by Jan Molak and the Serenity/JS project, it re-centers UI automation around the user rather than the UI. Instead of asking "what can this page do?" it asks "what does this actor need to accomplish?" That shift in framing - from technical surface to user intent - is small on paper and transformative in practice.

This article examines the Screenplay Pattern from multiple angles: its theoretical grounding in SOLID principles and established design patterns, a practical TypeScript implementation, and an honest accounting of its trade-offs and failure modes. The goal is not to sell the pattern but to equip you to evaluate it clearly and apply it well.

The Problem with Page Objects at Scale

The Page Object Model (POM) was a genuine improvement over raw Selenium calls scattered across test methods. Centralizing selectors and interaction logic into a class per page reduced duplication and gave tests a vocabulary that tracked the UI's structure. For small suites on simple apps, it still works fine.

The cracks appear when the application grows. A "page" in a modern single-page application is rarely a discrete screen - it is a collection of components, some shared across routes, some conditionally rendered, some loaded asynchronously. Page Objects written to mirror the URL structure begin to misrepresent reality. Teams add methods to every page class that happens to host a particular widget, and the widget's logic scatters. The LoginPage class ends up with authentication logic, navigation logic, error-message assertions, and convenience methods for downstream tests - violating the Single Responsibility Principle from the first week.

There is also a deeper structural problem: Page Objects mix two fundamentally different concerns - how to interact with the UI (selectors, waits, WebDriver calls) and what a user wants to do (log in, add an item to a cart, verify a confirmation). When those are fused into a single abstraction, tests that want to express user intent are forced to speak the language of DOM manipulation. The result is test code that reads like a Selenium tutorial rather than a specification.

SOLID Principles as the Pattern's Foundation

The Screenplay Pattern did not invent new ideas - it applied existing object-oriented design principles to a domain where they had rarely been applied systematically. Understanding which principles map to which pattern components is the fastest way to internalize the design.

Single Responsibility Principle (SRP) is the most visible. Each component class in Screenplay does exactly one thing. A Task represents a high-level user goal; an Action represents a single, atomic interaction with the UI; a Question retrieves observable state. None of these classes handles more than its defined concern. The contrast with Page Objects - where a single class handles navigation, interaction, state retrieval, and sometimes test data management - is stark.

Open/Closed Principle (OCP) manifests in the Ability system. An Actor is equipped with Abilities (the capacity to browse the web, call an API, read a file). New interaction capabilities can be added by writing new Ability classes without modifying the Actor or any existing Tasks. Tests that use the Actor are closed to modification when new integration points are added. In a Page Object model, adding a new interaction type often requires retrofitting existing class hierarchies or adding base-class methods that all subclasses inherit regardless of relevance.

Liskov Substitution Principle (LSP) applies primarily to the Task and Action interfaces. Because both implement a common Performable interface, any composition that accepts a Task will accept an Action - which enables the recursive composability that makes the pattern powerful. A Task can be composed of other Tasks or of Actions without the caller caring about the difference.

Interface Segregation Principle (ISP) is expressed in the separation between Performable (things an Actor can do) and Question (things an Actor can ask). These are deliberately separate interfaces. Code that only needs to ask questions is not forced to depend on performable behavior. This is a small but meaningful distinction that becomes important when building assertion utilities and reporting infrastructure.

Dependency Inversion Principle (DIP) is perhaps the most architecturally significant. Tasks and Actions depend on abstractions - the Actor interface and the BrowseTheWeb ability interface - not on concrete WebDriver instances. This is what makes Screenplay-based tests straightforwardly testable in isolation and swappable between browser drivers, mobile frameworks, or even mock environments.

Core Components and Design Patterns

The Screenplay Pattern is built from a small set of collaborating objects. The design patterns underlying them are not obscure - they are standard patterns from Gamma et al. and the broader catalog, applied consistently.

Actor

The Actor is the central coordinating object. It holds a set of Abilities and exposes two primary methods: attemptsTo(...performables) and asks(question). It is essentially a Facade over its Abilities, and it plays the role of Command invoker in the Command pattern - it executes Performables without knowing their internal logic.

// Simplified Actor implementation
interface Performable {
  performAs(actor: Actor): Promise<void>;
}

interface Question<T> {
  answeredBy(actor: Actor): Promise<T>;
}

class Actor {
  private abilities: Map<symbol, unknown> = new Map();

  constructor(private readonly name: string) {}

  whoCan(...newAbilities: Ability[]): Actor {
    newAbilities.forEach((ability) => {
      this.abilities.set(ability.key, ability);
    });
    return this;
  }

  abilityTo<T extends Ability>(abilityType: AbilityConstructor<T>): T {
    const ability = this.abilities.get(abilityType.key);
    if (!ability) {
      throw new Error(
        `${this.name} does not have the ability to ${abilityType.name}. ` +
          `Did you forget to call whoCan(${abilityType.name}.using(...))?`,
      );
    }
    return ability as T;
  }

  async attemptsTo(...activities: Performable[]): Promise<void> {
    for (const activity of activities) {
      await activity.performAs(this);
    }
  }

  async asks<T>(question: Question<T>): Promise<T> {
    return question.answeredBy(this);
  }
}

Abilities

An Ability encapsulates access to an external system. The canonical example is BrowseTheWeb, which wraps a WebDriver instance. The pattern here is Adapter: it translates the generic Actor API into the specific calls required by the underlying driver.

// BrowseTheWeb ability - adapter over a WebDriver-like interface
import { WebDriver, By, WebElement } from "selenium-webdriver";

const BROWSE_THE_WEB = Symbol("BrowseTheWeb");

class BrowseTheWeb implements Ability {
  static key = BROWSE_THE_WEB;

  static using(driver: WebDriver): BrowseTheWeb {
    return new BrowseTheWeb(driver);
  }

  static as(actor: Actor): BrowseTheWeb {
    return actor.abilityTo(BrowseTheWeb);
  }

  constructor(private readonly driver: WebDriver) {}

  navigateTo(url: string): Promise<void> {
    return this.driver.get(url);
  }

  locate(locator: By): Promise<WebElement> {
    return this.driver.findElement(locator);
  }

  executeScript<T>(script: string, ...args: unknown[]): Promise<T> {
    return this.driver.executeScript(script, ...args) as Promise<T>;
  }
}

Tasks and Actions

Tasks and Actions both implement Performable. The distinction is semantic and hierarchical: Actions are atomic - a single Click, a Type, a Navigate. Tasks are composite - a SignIn task is composed of navigate, type, click, and wait actions. This is a direct application of the Composite pattern, and it is the source of the pattern's composability.

// Action: atomic, reusable, UI-framework-aware
class Click implements Performable {
  static on(locator: By): Click {
    return new Click(locator);
  }

  constructor(private readonly locator: By) {}

  async performAs(actor: Actor): Promise<void> {
    const browser = BrowseTheWeb.as(actor);
    const element = await browser.locate(this.locator);
    await element.click();
  }
}

class Type implements Performable {
  static theValue(value: string): { into: (locator: By) => Type } {
    return {
      into: (locator: By) => new Type(value, locator),
    };
  }

  constructor(
    private readonly value: string,
    private readonly locator: By,
  ) {}

  async performAs(actor: Actor): Promise<void> {
    const browser = BrowseTheWeb.as(actor);
    const element = await browser.locate(this.locator);
    await element.sendKeys(this.value);
  }
}

// Task: composed of Actions, expresses user intent
class SignIn implements Performable {
  static as(username: string, password: string): SignIn {
    return new SignIn(username, password);
  }

  constructor(
    private readonly username: string,
    private readonly password: string,
  ) {}

  async performAs(actor: Actor): Promise<void> {
    await actor.attemptsTo(
      Navigate.to("/login"),
      Type.theValue(this.username).into(By.id("username")),
      Type.theValue(this.password).into(By.id("password")),
      Click.on(By.css('[data-testid="submit"]')),
      Wait.until(Visibility.of(By.css(".dashboard-header"))),
    );
  }
}

Questions

Questions implement a separate interface and follow the Query Object pattern. They encapsulate the logic of extracting observable state from the system under test, returning typed values that can be used in assertions.

// Question: retrieves observable state
class Text implements Question<string> {
  static of(locator: By): Text {
    return new Text(locator);
  }

  constructor(private readonly locator: By) {}

  async answeredBy(actor: Actor): Promise<string> {
    const browser = BrowseTheWeb.as(actor);
    const element = await browser.locate(this.locator);
    return element.getText();
  }
}

// Usage in a test
const welcomeMessage = await anna.asks(Text.of(By.css(".welcome-banner")));
expect(welcomeMessage).toContain("Welcome, Anna");

Putting It Together: A Full Test Scenario

// test/checkout.spec.ts
describe("Checkout flow", () => {
  let anna: Actor;

  beforeEach(() => {
    anna = new Actor("Anna").whoCan(
      BrowseTheWeb.using(driver),
      CallAnAPI.using(apiClient),
    );
  });

  it("allows a registered customer to complete a purchase", async () => {
    await anna.attemptsTo(
      SignIn.as("anna@example.com", "correct-password"),
      AddItemToCart.withSku("WIDGET-001"),
      ProceedToCheckout.usingDefaultAddress(),
      ConfirmOrder.payingWith("saved-card"),
    );

    const confirmationNumber = await anna.asks(
      Text.of(By.css('[data-testid="order-confirmation-number"]')),
    );

    expect(confirmationNumber).toMatch(/ORD-\d{8}/);
  });
});

The test reads as a specification. It asserts at the level of business behavior, not DOM structure. If the checkout flow's HTML changes, only the Actions and Questions inside the Tasks need updating - the test itself remains stable.

Pitfalls and Where the Pattern Breaks Down

The Screenplay Pattern's strengths create corresponding failure modes. Understanding these is as important as understanding the pattern itself.

Over-decomposition is the most common pitfall. When teams internalize "Actions should be atomic," they sometimes create an Action for every conceivable micro-interaction: ClickButton, ClickLink, ClickMenuItem, ClickDropdownOption. These are not meaningfully different abstractions - they are CSS selector wrappers. The correct unit of Action is the smallest interaction that carries standalone meaning in a user context, not the smallest possible WebDriver call. If you are creating more Actions than a Page Object Model would have methods, you have inverted the problem.

Fluent builder APIs become unreadable at depth. The Type.theValue('x').into(By.id('y')) idiom reads naturally for simple cases. But when a Task requires six or eight parameters - as checkout and onboarding flows often do - the builder chains grow unwieldy. Teams reach for data objects (plain TypeScript interfaces or classes carrying test data) sooner than they expect. This is fine, but the pattern does not prescribe how to handle it, and ad-hoc solutions proliferate.

The Actor.abilityTo() call is a hidden runtime dependency. When a Task calls BrowseTheWeb.as(actor) inside performAs, there is no compile-time guarantee that the Actor executing the Task has that Ability. The failure is a thrown exception at runtime, which in a test context means an error that looks like a test infrastructure failure rather than a test failure. Teams that add a new Ability partway through a test suite often discover missing Ability configurations through confusing CI errors. Mitigate this with factory functions that construct fully-configured Actors and enforce Ability presence.

Shared state between Tasks requires care. The pure Screenplay model has no mechanism for passing return values between Tasks in an attemptsTo call. If CreateDraftOrder needs to hand an order ID to ConfirmOrder, you either chain them into a higher-level Task that manages state internally, or you use a side-channel (actor memory, test context object, closure). All of these work; none is prescribed by the pattern. Teams that do not agree on a convention end up with four approaches in the same suite.

The pattern has a steep onboarding curve. Developers familiar with Page Objects can contribute to a POM suite on day one. Screenplay requires understanding Actors, Abilities, Tasks, Actions, Questions, and their composition rules before writing a first meaningful test. On small teams or projects with high contributor turnover, this cost is real and should be weighed honestly.

Best Practices for Production-Grade Screenplay Suites

Experience with the pattern at scale produces a set of practices that are not obvious from the documentation but significantly affect maintainability.

Establish naming conventions and enforce them. Task names should be gerund phrases expressing user intent: SigningIn, AddingItemToCart, CompletingCheckout. Action names should be imperative: Click, Type, Navigate, Wait. Question names should be noun phrases: Text, Visibility, SelectedValue. These conventions make the composition read naturally and help reviewers quickly identify which layer a class belongs to without reading its implementation.

Keep the UI layer thin - push business logic up. Actions should contain only the mechanics of interacting with an element. Decisions about which element to interact with based on test data should live in Tasks. If you find a Click Action inspecting values from the actor to decide what to do, you have slipped business logic into the wrong layer. Refactor by introducing a Task that makes the decision and delegates to the appropriate Actions.

Version your locator strategy separately from your Task strategy. Define locators as named constants or a lightweight locator library, not inline in Actions. When the application ships a UI refactor, you want to update locators in one place without touching Action logic. This is not unique to Screenplay - it is sound practice in any automation approach - but Screenplay's layered structure makes it easier to enforce because the boundary between "what to do" and "where to click" is architecturally explicit.

Write integration-level Tests for your Tasks. Because Tasks are plain objects with a performAs method, they can be unit-tested by injecting a mock Actor with a mock Ability. This enables fast feedback on Task composition logic without running a full browser. Teams that do this catch composition errors in seconds rather than waiting for CI browser runs.

// Testing a Task in isolation with a mock Actor
describe("SignIn task", () => {
  it("performs navigation, input, and submission in order", async () => {
    const performedActivities: string[] = [];

    const mockActor = {
      attemptsTo: async (...activities: Performable[]) => {
        for (const activity of activities) {
          performedActivities.push(activity.constructor.name);
        }
      },
      asks: async () => "",
      abilityTo: () => ({}),
    } as unknown as Actor;

    await SignIn.as("user@example.com", "pass").performAs(mockActor);

    expect(performedActivities).toEqual([
      "Navigate",
      "Type",
      "Type",
      "Click",
      "Wait",
    ]);
  });
});

Use a cast or fixture to manage Actor lifecycle. In practice, tests need Actors created before each test and torn down after. A Cast class - a factory that knows how to create fully-configured Actors with the right Abilities for the test environment - centralizes this logic and makes configuration changes (swapping drivers, pointing to a different environment) a one-line change.

// Cast pattern for managing Actor lifecycle
class StageHands {
  static actorNamed(name: string, env: TestEnvironment): Actor {
    return new Actor(name).whoCan(
      BrowseTheWeb.using(env.driver),
      CallAnAPI.using(env.apiClient),
      AccessLocalStorage.using(env.driver),
    );
  }
}

// In test setup
beforeEach(() => {
  actor = StageHands.actorNamed("Tester", testEnv);
});

Screenplay Beyond Web UI: Broader Applicability

The Screenplay Pattern's value extends beyond browser automation. Its core insight - model the user, not the interface - applies anywhere tests interact with a system through multiple surfaces.

In mobile testing, the same pattern maps cleanly onto Appium. The BrowseTheWeb Ability is replaced by an OperateMobileDevice Ability; Actions wrap mobile-specific gestures (tap, swipe, long-press); Tasks remain unchanged. A SignIn Task that worked in browser testing can be reused in mobile testing with a different Ability injected. This level of reuse is difficult to achieve in a Page Object model where the UI surface is baked into the class hierarchy.

In API testing, an actor equipped with CallAnAPI can execute Tasks that involve making HTTP requests and asserting on response shapes. When an end-to-end test requires setting up state via API before driving the UI, the same Actor can switch between Abilities in a single test. A task like CreateUserViaAPI can be composed with VerifyUserAppearsInAdminUI - mixing API and browser interactions - without any architectural gymnastics.

For teams building with accessibility in mind, Questions can be written to interrogate ARIA attributes and roles rather than visual state. AccessibilityRole.of(By.css('.dialog')) returning 'dialog' is as natural as Text.of(By.css('.dialog h2')) returning a heading string. The pattern encourages thinking about what the system exposes to the user rather than what the DOM technically contains.

Key Takeaways

The Screenplay Pattern is a significant conceptual investment. For teams ready to make it, these five practices produce the most leverage:

  1. Start with Tasks, not Actions. When introducing the pattern to an existing suite, identify the three to five highest-value user journeys and model them as Tasks first. Actions and Questions follow naturally from what the Tasks need.

  2. Enforce the layer boundary with naming and package structure. Put Tasks, Actions, and Questions in separate directories. Reviewers enforcing a naming convention during code review prevent layer pollution from the start.

  3. Write the Cast early. Centralizing Actor construction prevents Ability configuration drift across test files. Do this in week one, before the suite grows.

  4. Prefer composition over parameter explosion. When a Task requires more than three or four parameters, introduce a data object. Let the Task accept that object rather than a growing parameter list.

  5. Make Questions typed and specific. A Question<boolean> that answers "is this element visible?" is more composable and easier to assert on than a Question<WebElement> that forces the caller to inspect properties.

80/20 Insight

If you grasp only two things about the Screenplay Pattern, let them be these:

The Actor-Task-Action hierarchy is a direct application of the Composite pattern enforced by a consistent interface. Once you see Tasks as Composites of Performables, the entire design snaps into focus. You are not learning a testing framework - you are applying a pattern you already know to a new domain.

The separation of Questions from Performables is the move that makes assertions stable. Page Objects that expose WebElements for callers to inspect put the DOM in the assertion layer. Questions that return typed values (strings, booleans, numbers, domain objects) put the intent in the assertion layer. Assertions on intent are stable; assertions on DOM details are fragile.

Everything else in the pattern - the Ability adapters, the Cast factory, the fluent builder conventions - is scaffolding around these two ideas.

Conclusion

The Screenplay Pattern is not a silver bullet, and it is not the right choice for every context. Small suites on simple apps do not need the architecture. Teams without a culture of code review to enforce layering will not sustain the discipline. Projects with short lifespans do not justify the onboarding investment.

But for teams building automation suites that need to survive multiple years, multiple UI frameworks, and multiple contributors - the pattern's grounding in SOLID principles and established design patterns gives it a structural robustness that ad-hoc Page Object suites rarely develop. Tests written at the level of user intent do not break when CSS classes change. Actors equipped with swappable Abilities do not require suite-wide refactors when the application adds a mobile surface. Questions returning typed values produce assertions that read as specifications, not as DOM inspections.

The Screenplay Pattern is ultimately about applying the same engineering discipline to test code that we expect of production code. The insight is not that test code should be more complex - it is that complexity should be organized. An actor navigating a checkout flow should read like a user navigating a checkout flow. That alignment between intent and implementation is what the pattern, at its best, delivers.

References

  1. Serenity/JS Documentation - Official implementation of the Screenplay Pattern for JavaScript/TypeScript. https://serenity-js.org/handbook/design/screenplay-pattern/
  2. Molak, Jan - Serenity/JS: Screenplay Pattern. Ongoing documentation and blog series at serenity-js.org.
  3. Marcano, Antony; Barnes, Andy; Hare, John; Molak, Jan - Page Objects Refactored: SOLID Steps to the Screenplay/Journey Pattern. Presented at ACCU 2016. Available via InfoQ.
  4. Martin, Robert C. - Agile Software Development, Principles, Patterns, and Practices. Prentice Hall, 2002. (Canonical reference for SOLID principles.)
  5. Gamma, Erich; Helm, Richard; Johnson, Ralph; Vlissides, John - Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994. (Composite, Command, Adapter, and Facade patterns referenced throughout.)
  6. Selenium WebDriver Documentation - WebDriver W3C specification and API reference. https://www.selenium.dev/documentation/
  7. Appium Documentation - Mobile automation framework. https://appium.io/docs/en/latest/
  8. Fowler, Martin - PageObject. MartinFowler.com, 2013. https://martinfowler.com/bliki/PageObject.html (Essential context for the problem POM solves and the limits it runs into.)
  9. Freeman, Steve; Pryce, Nat - Growing Object-Oriented Software, Guided by Tests. Addison-Wesley, 2009. (Foundational treatment of test design as software design.)