Screenplay Pattern
A user-centred alternative to Page Objects built from Actors, Abilities, Tasks, Interactions and Questions. When it beats POM, when it does not, and a complete implementation in four languages.
Page Objects scale until they do not. Around the fiftieth page class, methods like loginAndGoToOrdersAndFilterByStatus() appear, page classes start calling each other, and nobody knows where a given behaviour lives. The Screenplay pattern (from the Serenity BDD community, originally “Journey pattern”) restructures tests around what a user is trying to do rather than which page they are on. It is heavier to start and pays off on large suites with many contributors.
The Vocabulary
| Concept | Meaning | Example |
|---|---|---|
| Actor | Who is performing the test | Actor.named("Priya") |
| Ability | What the actor can do | BrowseTheWeb.with(driver) |
| Task | A business-level goal composed of smaller steps | Login.as(user), PlaceOrder.for(items) |
| Interaction | One low-level action against the UI | Click.on(SUBMIT), Enter.text("qa@x.com").into(EMAIL) |
| Question | Something the actor can observe | TheOrderTotal.value(), TheText.of(WELCOME_BANNER) |
| Target | A named locator | Target.the("submit button").located(By.id("submit")) |
A test reads as a script: an actor with abilities attempts tasks and then answers questions.
The Minimal Core
You can adopt Screenplay without a framework. The whole engine is two interfaces and an actor class.
public interface Performable { void performAs(Actor actor);}
public interface Question<T> { T answeredBy(Actor actor);}
public interface Ability {}
public final class Actor { private final String name; private final Map<Class<? extends Ability>, Ability> abilities = new HashMap<>();
private Actor(String name) { this.name = name; } public static Actor named(String name) { return new Actor(name); }
public Actor can(Ability ability) { abilities.put(ability.getClass(), ability); return this; }
public <T extends Ability> T abilityTo(Class<T> type) { Ability a = abilities.get(type); if (a == null) throw new IllegalStateException(name + " cannot " + type.getSimpleName()); return type.cast(a); }
public Actor attemptsTo(Performable... tasks) { for (Performable t : tasks) t.performAs(this); return this; }
public <T> T asks(Question<T> question) { return question.answeredBy(this); }}
public final class BrowseTheWeb implements Ability { public final WebDriver driver; public final WebDriverWait wait; private BrowseTheWeb(WebDriver driver) { this.driver = driver; this.wait = new WebDriverWait(driver, Duration.ofSeconds(10)); } public static BrowseTheWeb with(WebDriver driver) { return new BrowseTheWeb(driver); } public static BrowseTheWeb as(Actor actor) { return actor.abilityTo(BrowseTheWeb.class); }}from typing import Protocol, TypeVar, Generic
T = TypeVar("T")
class Performable(Protocol): def perform_as(self, actor: "Actor") -> None: ...
class Question(Protocol, Generic[T]): def answered_by(self, actor: "Actor") -> T: ...
class Actor: def __init__(self, name: str): self.name = name self._abilities: dict[type, object] = {}
@classmethod def named(cls, name: str) -> "Actor": return cls(name)
def can(self, ability) -> "Actor": self._abilities[type(ability)] = ability return self
def ability_to(self, ability_type: type[T]) -> T: try: return self._abilities[ability_type] except KeyError: raise RuntimeError(f"{self.name} cannot {ability_type.__name__}")
def attempts_to(self, *tasks: Performable) -> "Actor": for task in tasks: task.perform_as(self) return self
def asks(self, question: Question[T]) -> T: return question.answered_by(self)
class BrowseTheWeb: def __init__(self, driver): self.driver = driver self.wait = WebDriverWait(driver, 10)
@classmethod def using(cls, driver) -> "BrowseTheWeb": return cls(driver)
@staticmethod def as_(actor: Actor) -> "BrowseTheWeb": return actor.ability_to(BrowseTheWeb)class Actor {#abilities = new Map();constructor(name) { this.name = name; }static named(name) { return new Actor(name); }
can(ability) { this.#abilities.set(ability.constructor, ability); return this; }
abilityTo(type) { const a = this.#abilities.get(type); if (!a) throw new Error(`${this.name} cannot ${type.name}`); return a;}
async attemptsTo(...tasks) { for (const t of tasks) await t.performAs(this); return this;}
asks(question) { return question.answeredBy(this); }}
class BrowseTheWeb {constructor(driver) { this.driver = driver; }static with(driver) { return new BrowseTheWeb(driver); }static as(actor) { return actor.abilityTo(BrowseTheWeb); }}public interface IPerformable { void PerformAs(Actor actor); }public interface IQuestion<T> { T AnsweredBy(Actor actor); }public interface IAbility { }
public sealed class Actor{ private readonly Dictionary<Type, IAbility> _abilities = new(); public string Name { get; } private Actor(string name) => Name = name; public static Actor Named(string name) => new(name);
public Actor Can(IAbility ability) { _abilities[ability.GetType()] = ability; return this; }
public T AbilityTo<T>() where T : IAbility => _abilities.TryGetValue(typeof(T), out var a) ? (T)a : throw new InvalidOperationException($"{Name} cannot {typeof(T).Name}");
public Actor AttemptsTo(params IPerformable[] tasks) { foreach (var t in tasks) t.PerformAs(this); return this; }
public T Asks<T>(IQuestion<T> question) => question.AnsweredBy(this);}
public sealed class BrowseTheWeb : IAbility{ public IWebDriver Driver { get; } public WebDriverWait Wait { get; } private BrowseTheWeb(IWebDriver driver) { Driver = driver; Wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10)); } public static BrowseTheWeb With(IWebDriver driver) => new(driver); public static BrowseTheWeb As(Actor actor) => actor.AbilityTo<BrowseTheWeb>();}Interactions: The Reusable Leaf Actions
Interactions wrap one WebDriver call plus its wait. Write them once; every task uses them.
public record Target(String name, By locator) { public static Target the(String name) { return new Target(name, null); } public Target located(By by) { return new Target(name, by); }}
public final class Click implements Performable { private final Target target; private Click(Target target) { this.target = target; } public static Click on(Target target) { return new Click(target); }
@Override public void performAs(Actor actor) { BrowseTheWeb web = BrowseTheWeb.as(actor); web.wait.until(ExpectedConditions.elementToBeClickable(target.locator())).click(); }}
public final class Enter implements Performable { private final String text; private Target target; private Enter(String text) { this.text = text; } public static Enter text(String text) { return new Enter(text); } public Enter into(Target target) { this.target = target; return this; }
@Override public void performAs(Actor actor) { WebElement el = BrowseTheWeb.as(actor).wait.until( ExpectedConditions.visibilityOfElementLocated(target.locator())); el.clear(); el.sendKeys(text); }}
public final class Open implements Performable { private final String url; private Open(String url) { this.url = url; } public static Open url(String url) { return new Open(url); } @Override public void performAs(Actor actor) { BrowseTheWeb.as(actor).driver.get(url); }}from dataclasses import dataclass
@dataclass(frozen=True)class Target: name: str locator: tuple
@dataclass(frozen=True)class Click: target: Target @classmethod def on(cls, target: Target): return cls(target) def perform_as(self, actor: Actor) -> None: web = BrowseTheWeb.as_(actor) web.wait.until(EC.element_to_be_clickable(self.target.locator)).click()
@dataclass(frozen=True)class Enter: text: str target: Target | None = None @classmethod def the_text(cls, text: str): return cls(text) def into(self, target: Target): return Enter(self.text, target) def perform_as(self, actor: Actor) -> None: el = BrowseTheWeb.as_(actor).wait.until(EC.visibility_of_element_located(self.target.locator)) el.clear() el.send_keys(self.text)
@dataclass(frozen=True)class Open: url: str @classmethod def url_(cls, url: str): return cls(url) def perform_as(self, actor: Actor) -> None: BrowseTheWeb.as_(actor).driver.get(self.url)const Target = (name, locator) => ({ name, locator });
class Click {constructor(target) { this.target = target; }static on(target) { return new Click(target); }async performAs(actor) { const { driver } = BrowseTheWeb.as(actor); const el = await driver.wait(until.elementLocated(this.target.locator), 10000); await driver.wait(until.elementIsEnabled(el), 10000); await el.click();}}
class Enter {constructor(text) { this.text = text; }static text(text) { return new Enter(text); }into(target) { this.target = target; return this; }async performAs(actor) { const { driver } = BrowseTheWeb.as(actor); const el = await driver.wait(until.elementLocated(this.target.locator), 10000); await el.clear(); await el.sendKeys(this.text);}}
class Open {constructor(url) { this.url = url; }static url(url) { return new Open(url); }async performAs(actor) { await BrowseTheWeb.as(actor).driver.get(this.url); }}public sealed record Target(string Name, By Locator){ public static Target The(string name) => new(name, null!); public Target LocatedBy(By by) => this with { Locator = by };}
public sealed class Click : IPerformable{ private readonly Target _target; private Click(Target t) => _target = t; public static Click On(Target t) => new(t); public void PerformAs(Actor actor) { var web = BrowseTheWeb.As(actor); web.Wait.Until(d => { var e = d.FindElement(_target.Locator); return e.Displayed && e.Enabled ? e : null; }).Click(); }}
public sealed class Enter : IPerformable{ private readonly string _text; private Target _target = null!; private Enter(string text) => _text = text; public static Enter Text(string text) => new(text); public Enter Into(Target t) { _target = t; return this; } public void PerformAs(Actor actor) { var el = BrowseTheWeb.As(actor).Wait.Until(d => { var e = d.FindElement(_target.Locator); return e.Displayed ? e : null; }); el.Clear(); el.SendKeys(_text); }}
public sealed class Open : IPerformable{ private readonly string _url; private Open(string url) => _url = url; public static Open Url(string url) => new(url); public void PerformAs(Actor actor) => BrowseTheWeb.As(actor).Driver.Navigate().GoToUrl(_url);}Tasks and Questions: The Business Layer
Tasks compose interactions (and other tasks) into goals. Questions read state. Targets live next to the tasks that use them, grouped by feature rather than by page.
public final class LoginPage { public static final Target EMAIL = Target.the("email field").located(By.id("email")); public static final Target PASSWORD = Target.the("password field").located(By.id("password")); public static final Target SIGN_IN = Target.the("sign in button").located(By.cssSelector("button[type=submit]")); public static final Target WELCOME = Target.the("welcome banner").located(By.cssSelector("h1.welcome"));}
public final class Login implements Performable { private final String email, password; private Login(String email, String password) { this.email = email; this.password = password; } public static Login as(String email, String password) { return new Login(email, password); }
@Override public void performAs(Actor actor) { actor.attemptsTo( Open.url("https://app.example.com/login"), Enter.text(email).into(LoginPage.EMAIL), Enter.text(password).into(LoginPage.PASSWORD), Click.on(LoginPage.SIGN_IN) ); }}
public final class TheText implements Question<String> { private final Target target; private TheText(Target t) { this.target = t; } public static TheText of(Target t) { return new TheText(t); } @Override public String answeredBy(Actor actor) { return BrowseTheWeb.as(actor).wait.until( ExpectedConditions.visibilityOfElementLocated(target.locator())).getText(); }}
// The test@Testvoid userSeesWelcomeAfterLogin() { Actor priya = Actor.named("Priya").can(BrowseTheWeb.with(driver));
priya.attemptsTo(Login.as("priya@example.com", "secret"));
assertEquals("Welcome, Priya", priya.asks(TheText.of(LoginPage.WELCOME)));}class LoginPage: EMAIL = Target("email field", (By.ID, "email")) PASSWORD = Target("password field", (By.ID, "password")) SIGN_IN = Target("sign in button", (By.CSS_SELECTOR, "button[type=submit]")) WELCOME = Target("welcome banner", (By.CSS_SELECTOR, "h1.welcome"))
@dataclass(frozen=True)class Login: email: str password: str @classmethod def as_(cls, email, password): return cls(email, password) def perform_as(self, actor: Actor) -> None: actor.attempts_to( Open.url_("https://app.example.com/login"), Enter.the_text(self.email).into(LoginPage.EMAIL), Enter.the_text(self.password).into(LoginPage.PASSWORD), Click.on(LoginPage.SIGN_IN), )
@dataclass(frozen=True)class TheText: target: Target @classmethod def of(cls, target): return cls(target) def answered_by(self, actor: Actor) -> str: return BrowseTheWeb.as_(actor).wait.until( EC.visibility_of_element_located(self.target.locator)).text
def test_user_sees_welcome_after_login(driver): priya = Actor.named("Priya").can(BrowseTheWeb.using(driver))
priya.attempts_to(Login.as_("priya@example.com", "secret"))
assert priya.asks(TheText.of(LoginPage.WELCOME)) == "Welcome, Priya"const LoginPage = {EMAIL: Target('email field', By.id('email')),PASSWORD: Target('password field', By.id('password')),SIGN_IN: Target('sign in button', By.css('button[type=submit]')),WELCOME: Target('welcome banner', By.css('h1.welcome')),};
class Login {constructor(email, password) { Object.assign(this, { email, password }); }static as(email, password) { return new Login(email, password); }async performAs(actor) { await actor.attemptsTo( Open.url('https://app.example.com/login'), Enter.text(this.email).into(LoginPage.EMAIL), Enter.text(this.password).into(LoginPage.PASSWORD), Click.on(LoginPage.SIGN_IN), );}}
class TheText {constructor(target) { this.target = target; }static of(target) { return new TheText(target); }async answeredBy(actor) { const { driver } = BrowseTheWeb.as(actor); const el = await driver.wait(until.elementLocated(this.target.locator), 10000); return el.getText();}}
it('user sees welcome after login', async () => {const priya = Actor.named('Priya').can(BrowseTheWeb.with(driver));await priya.attemptsTo(Login.as('priya@example.com', 'secret'));assert.strictEqual(await priya.asks(TheText.of(LoginPage.WELCOME)), 'Welcome, Priya');});public static class LoginPage{ public static readonly Target Email = Target.The("email field").LocatedBy(By.Id("email")); public static readonly Target Password = Target.The("password field").LocatedBy(By.Id("password")); public static readonly Target SignIn = Target.The("sign in button").LocatedBy(By.CssSelector("button[type=submit]")); public static readonly Target Welcome = Target.The("welcome banner").LocatedBy(By.CssSelector("h1.welcome"));}
public sealed class Login : IPerformable{ private readonly string _email, _password; private Login(string e, string p) { _email = e; _password = p; } public static Login As(string e, string p) => new(e, p); public void PerformAs(Actor actor) => actor.AttemptsTo( Open.Url("https://app.example.com/login"), Enter.Text(_email).Into(LoginPage.Email), Enter.Text(_password).Into(LoginPage.Password), Click.On(LoginPage.SignIn));}
public sealed class TheText : IQuestion<string>{ private readonly Target _t; private TheText(Target t) => _t = t; public static TheText Of(Target t) => new(t); public string AnsweredBy(Actor actor) => BrowseTheWeb.As(actor).Wait.Until(d => { var e = d.FindElement(_t.Locator); return e.Displayed ? e : null; }).Text;}
[Test]public void UserSeesWelcomeAfterLogin(){ var priya = Actor.Named("Priya").Can(BrowseTheWeb.With(driver)); priya.AttemptsTo(Login.As("priya@example.com", "secret")); Assert.That(priya.Asks(TheText.Of(LoginPage.Welcome)), Is.EqualTo("Welcome, Priya"));}Why Teams Adopt It
- Composition over inheritance: tasks compose; page objects tend to inherit and grow.
- Single responsibility: an interaction does one thing; a task expresses one goal; a question reads one fact. Nothing has 40 methods.
- Readable failures: because every step is a named object, reports can show “Priya attempts to Login as priya@example.com” and mark exactly which interaction failed.
- Multiple actors: two actors with separate drivers model a buyer and a seller in one test without global state.
- Other abilities:
CallAnApialongsideBrowseTheWeblets a task seed data through an API and verify it through the UI, without the test knowing which is which.
Why Teams Do Not
- More files and more ceremony for small suites; a ten-page app is fine with POM.
- Learning curve for people arriving from POM.
- Java has Serenity BDD with a polished Screenplay implementation; other languages have smaller ecosystems (ScreenPy for Python, Serenity/JS for JavaScript and TypeScript, Boa Constrictor for C#).
Migrating From Page Objects Gradually
You do not have to rewrite. Wrap existing page objects as abilities or interactions, write new features in Screenplay, and retire page objects as they are touched. A page object’s methods map one-to-one onto tasks; its locators become targets.
Summary
- Screenplay organises tests around actors and goals, using composable tasks, interactions and questions.
- The core is under 100 lines; frameworks add reporting and conveniences.
- It shines on large suites with many contributors and multi-actor scenarios, and is overkill for small ones.
- Introduce it incrementally next to existing page objects.