What Is the Screenplay Pattern?

The Screenplay pattern is an advanced alternative to the Page Object Model. Instead of organizing tests around pages, it organizes them around actors who perform tasks and ask questions using their abilities.

Think of it like a screenplay for a movie — you describe what actors do, not what the UI looks like.

POM vs Screenplay: A Comparison

POM approach:

const loginPage = new LoginPage(page);
await loginPage.login('admin@test.com', 'secret');
const dashboard = new DashboardPage(page);
const name = await dashboard.getUserName();
expect(name).toBe('Admin');

Screenplay approach:

await actor.attemptsTo(
  Login.withCredentials('admin@test.com', 'secret'),
  Navigate.to(Dashboard)
);
const name = await actor.asks(UserName.displayed());
expect(name).toBe('Admin');

The Screenplay version reads more like a natural language description of user behavior.

The Four Building Blocks

1. Actors

An Actor represents a user of the system with specific abilities and context.

class Actor {
  constructor(name) {
    this.name = name;
    this.abilities = new Map();
  }

  can(ability) {
    this.abilities.set(ability.constructor.name, ability);
    return this;
  }

  abilityTo(AbilityClass) {
    return this.abilities.get(AbilityClass.name);
  }

  async attemptsTo(...tasks) {
    for (const task of tasks) {
      await task.performAs(this);
    }
  }

  async asks(question) {
    return await question.answeredBy(this);
  }
}

// Creating actors
const admin = new Actor('Admin')
  .can(new BrowseTheWeb(page))
  .can(new CallAnApi('https://api.example.com'));

const customer = new Actor('Customer')
  .can(new BrowseTheWeb(page));

2. Abilities

Abilities define what an Actor can do — interact with a browser, call APIs, access databases.

class BrowseTheWeb {
  constructor(page) {
    this.page = page;
  }

  static using(page) {
    return new BrowseTheWeb(page);
  }
}

class CallAnApi {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }

  async get(path) {
    const response = await fetch(`${this.baseUrl}${path}`);
    return response.json();
  }

  async post(path, data) {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: 'POST',
      body: JSON.stringify(data),
      headers: { 'Content-Type': 'application/json' }
    });
    return response.json();
  }
}

3. Tasks

Tasks represent high-level user actions. They are composable — complex tasks are built from simpler ones.

class Login {
  constructor(email, password) {
    this.email = email;
    this.password = password;
  }

  static withCredentials(email, password) {
    return new Login(email, password);
  }

  async performAs(actor) {
    const page = actor.abilityTo(BrowseTheWeb).page;
    await page.goto('/login');
    await page.fill('[data-testid="email"]', this.email);
    await page.fill('[data-testid="password"]', this.password);
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
  }
}

class AddProductToCart {
  constructor(productName) {
    this.productName = productName;
  }

  static called(productName) {
    return new AddProductToCart(productName);
  }

  async performAs(actor) {
    const page = actor.abilityTo(BrowseTheWeb).page;
    const card = page.locator(`.product:has-text("${this.productName}")`);
    await card.locator('.add-to-cart').click();
    await page.waitForSelector('.cart-updated');
  }
}

4. Questions

Questions allow actors to query the state of the system.

class UserName {
  static displayed() {
    return new UserName();
  }

  async answeredBy(actor) {
    const page = actor.abilityTo(BrowseTheWeb).page;
    return await page.textContent('.user-name');
  }
}

class CartItemCount {
  static shown() {
    return new CartItemCount();
  }

  async answeredBy(actor) {
    const page = actor.abilityTo(BrowseTheWeb).page;
    const text = await page.textContent('.cart-count');
    return parseInt(text);
  }
}

class PageTitle {
  static shown() {
    return new PageTitle();
  }

  async answeredBy(actor) {
    const page = actor.abilityTo(BrowseTheWeb).page;
    return await page.title();
  }
}

Writing Tests with Screenplay

test('customer can add items to cart and checkout', async ({ page }) => {
  const customer = new Actor('Customer')
    .can(BrowseTheWeb.using(page));

  await customer.attemptsTo(
    Login.withCredentials('customer@test.com', 'password'),
    AddProductToCart.called('Wireless Mouse'),
    AddProductToCart.called('USB Keyboard')
  );

  const itemCount = await customer.asks(CartItemCount.shown());
  expect(itemCount).toBe(2);

  await customer.attemptsTo(
    ProceedToCheckout.now(),
    EnterShippingAddress.with(testAddress),
    PayWith.card(testCard)
  );

  const orderNumber = await customer.asks(OrderNumber.displayed());
  expect(orderNumber).toMatch(/^ORD-\d+$/);
});

Composing Complex Tasks

One of Screenplay’s strengths is task composition:

class PlaceOrder {
  constructor(product, address, card) {
    this.product = product;
    this.address = address;
    this.card = card;
  }

  static for(product) {
    return {
      shippingTo: (address) => ({
        payingWith: (card) => new PlaceOrder(product, address, card)
      })
    };
  }

  async performAs(actor) {
    await actor.attemptsTo(
      AddProductToCart.called(this.product),
      ProceedToCheckout.now(),
      EnterShippingAddress.with(this.address),
      PayWith.card(this.card)
    );
  }
}

// High-level test
test('complete purchase flow', async ({ page }) => {
  const customer = new Actor('Customer').can(BrowseTheWeb.using(page));
  await customer.attemptsTo(
    Login.withCredentials('customer@test.com', 'password'),
    PlaceOrder.for('Wireless Mouse').shippingTo(homeAddress).payingWith(visa)
  );
});

Screenplay vs POM: When to Choose Which

FactorPOMScreenplay
Learning curveLowerHigher
Small projectsBetter fitOverkill
Complex workflowsCan struggleExcels
Multi-system testsLimitedNatural fit
Team sizeAnyMedium-large
ReadabilityGoodExcellent
ReusabilityPage-levelTask-level

Choose POM When:

  • Your application has clearly defined pages
  • Your team is small or new to automation
  • Tests are mostly single-page interactions
  • You need to get started quickly

Choose Screenplay When:

  • Workflows span multiple pages and systems
  • You need maximum reusability across different test suites
  • Your team is experienced and can handle the added complexity
  • Tests involve multiple user roles interacting with each other

Serenity/JS: Screenplay in Practice

Serenity/JS is a popular framework that implements the Screenplay pattern:

import { actorCalled, Actor } from '@serenity-js/core';
import { BrowseTheWebWithPlaywright } from '@serenity-js/playwright';
import { Navigate, Click, Enter } from '@serenity-js/web';

const actor = actorCalled('Customer')
  .whoCan(BrowseTheWebWithPlaywright.using(browser));

await actor.attemptsTo(
  Navigate.to('/login'),
  Enter.theValue('user@test.com').into('#email'),
  Enter.theValue('password').into('#password'),
  Click.on('#submit')
);

Exercise: Convert POM to Screenplay

Take the e-commerce POM from Lesson 8.8 and refactor it into the Screenplay pattern:

  1. Define an Actor with BrowseTheWeb ability
  2. Convert LoginPage.login() into a Login Task
  3. Convert CartPage.addItem() into an AddToCart Task
  4. Create Questions: CartTotal, ItemCount, OrderNumber
  5. Write a composite PlaceOrder Task
  6. Write 2 tests using the Screenplay approach

Compare the readability and flexibility of both approaches.

Key Takeaways

  • Screenplay organizes tests around Actors, Tasks, Questions, and Abilities
  • Tests read like natural language descriptions of user behavior
  • Tasks are composable — complex workflows are built from simple tasks
  • Screenplay excels in complex, multi-system projects
  • POM is simpler and better for straightforward web applications
  • Both patterns can coexist in the same project