The Screenplay Design Pattern

A Sustainable Way to Organize Complex Test Code

Michael Kutz

@MichaKutz

Page Objects and their short comings

Actors with
Abilities

Performing
Tasks

Answering Questions

Knowing Facts

Extension

the Code

JDK ≥ 11

a Java IDE (like IntelliJ IDEA)

Part 1: Page Objects

interacts with

Test

Test 2

interacts with

the same

By.name("username")
By.name("username")

Elements

PageObject

uses

uses

to interact with

By.name("username")
login(username, password)

Test

Test 2

Elements

public class RegisterPage extends Page {

  private static final By firstNameInput =
      By.id("customer.firstName");
  private static final By lastNameInput =
      By.id("customer.lastName");
  private static final By addressStreetInput =
      By.id("customer.address.street");
  private static final By addressCityInput =
      By.id("customer.address.city");
  private static final By addressStateInput =
      By.id("customer.address.state");

  // …

  public RegisterPage(WebDriver webDriver) {
    super(webDriver);
  }

  public void inputName(String firstName, String lastName) {
    webDriver.findElement(firstNameInput).sendKeys(firstName);
    webDriver.findElement(lastNameInput).sendKeys(lastName);
  }

  public void inputAddress(String street, String city,
      String state, String zipCode) {
    webDriver.findElement(addressStreetInput)
        .sendKeys(street);
    webDriver.findElement(addressCityInput)
        .sendKeys(city);
    webDriver.findElement(addressStateInput)
        .sendKeys(state);
    webDriver.findElement(addressZipCodeInput)
        .sendKeys(zipCode);
  }

  public void inputPhoneNumber(String phoneNumber) {
    webDriver.findElement(phoneNumberInput)
        .sendKeys(phoneNumber);
  }

  public void inputSsn(String ssn) {
    webDriver.findElement(ssnInput).sendKeys(ssn);
  }

  public void inputCredentials(String username,
        String password) {
    webDriver.findElement(usernameInput)
        .sendKeys(username);
    webDriver.findElement(passwordInput)
        .sendKeys(password);
    webDriver.findElement(passwordRepeatInput)
        .sendKeys(password);
  }

  public void submit() {
    webDriver.findElement(submitButton).click();
  }
}
public class AccountsOverviewPage extends Page {

  private static final By accountRows =
      By.cssSelector("#accountTable tbody tr.ng-scope");
  private static final By accountIdCell =
      By.cssSelector("td:nth-child(1)");
  private static final By balanceCell =
      By.cssSelector("td:nth-child(2)");

  public AccountsOverviewPage(WebDriver webDriver) {
    super(webDriver);
  }

  public int getBalanceInCents(int index) {
    return dollarStringToCents(
        new WebDriverWait(webDriver, 5)
            .until(visibilityOfAllElementsLocatedBy(accountRows))
            .get(index)
            .findElement(balanceCell)
            .getText()
    );
  }

  public int getBalanceInCents(String accountId) {
    return new WebDriverWait(webDriver, 5)
        .until(visibilityOfAllElementsLocatedBy(accountRows))
        .stream()
        .filter(accountRow ->
            accountRow.findElement(accountIdCell).getText()
                .equals(accountId)
        )
        .findAny()
        .map(accountRow ->
            accountRow.findElement(balanceCell).getText()
        )
        .map(AccountsOverviewPage::dollarStringToCents)
        .orElseThrow(() -> new IllegalArgumentException(
            "Account " + accountId + " not in account overview"));
  }

  private static int dollarStringToCents(String dollarString) {
    return Integer.parseInt(
        dollarString.replaceAll("[^-\\d]", "")
    );
  }
}
assertEquals(
  accountsOverviewPage.getBalanceInCents(fromAccountIndex),
  originalFromBalance - amountInCents);
assertEquals(
  accountsOverviewPage.getBalanceInCents(toAccountIndex),
  originalToBalance + amountInCents);
public class LoginTest extends BaseTest {

  @Test
  public void canLogin() {
    homePage.login(testUsername, testPassword);

    assertTrue(homePage.isLoggedIn());
  }
}
public class TransferFundsTest extends BaseTest {

  @Test
  public void transferFunds() {
    var fromAccountIndex = 0;
    var toAccountIndex = 1;
    var amountInCents = 1000;

    var openNewAccountPage = homePage.clickOpenNewAccountLink();
    openNewAccountPage.openAccount();

    var accountsOverviewPage = homePage.clickAccountsOverviewLink();
    var originalFromBalance = accountsOverviewPage
        .getBalanceInCents(fromAccountIndex);
    var originalToBalance = accountsOverviewPage
        .getBalanceInCents(toAccountIndex);

    var transferFundsPage = homePage.clickTransferFundsLink();
    transferFundsPage
        .transfer(amountInCents, fromAccountIndex, toAccountIndex);

    homePage.clickAccountsOverviewLink();
    assertEquals(
        accountsOverviewPage.getBalanceInCents(fromAccountIndex),
        originalFromBalance - amountInCents);
    assertEquals(
        accountsOverviewPage.getBalanceInCents(toAccountIndex),
        originalToBalance + amountInCents);
  }
}

Part 2: Actors with Abilities

Screenplay

Actor

Abilities

Elements

Actions

enable

has

directs

interact with

abstract public class BaseTest {

  private static WebDriver webDriver;
  protected HomePage homePage;
  protected String testUsername = "john";
  protected String testPassword = "demo";

  @BeforeAll
  static void setUpWebDriver() {
    WebDriverManager.chromiumdriver().setup();
    webDriver = new ChromeDriver();
  }

  @BeforeEach
  public void reset() {
    webDriver.manage().deleteAllCookies();
    webDriver.get("http://parabank.parasoft.com/");
    homePage = new HomePage(webDriver);
  }

  @AfterAll
  static void tearDownWebDriver() {
    webDriver.quit();
  }
}
abstract public class BaseScreenplay {

  private static WebDriver webDriver;
  protected HomePage homePage;
  protected String testUsername = "john";
  protected String testPassword = "demo";

  @BeforeAll
  static void setUpWebDriver() {
    WebDriverManager.chromiumdriver().setup();
    webDriver = new ChromeDriver();
  }

  @BeforeEach
  public void reset() {
    webDriver.manage().deleteAllCookies();
    webDriver.get("http://parabank.parasoft.com/");
    homePage = new HomePage(webDriver);
  }

  @AfterAll
  static void tearDownWebDriver() {
    webDriver.quit();
  }
}
abstract public class BaseScreenplay {

  private static WebDriver webDriver;
  protected Actor actor;
  protected String testUsername = "john";
  protected String testPassword = "demo";

  @BeforeAll
  static void setUpWebDriver() {
    WebDriverManager.chromiumdriver().setup();
    webDriver = new ChromeDriver();
  }

  @BeforeEach
  public void reset() {
    webDriver.manage().deleteAllCookies();
    webDriver.get("http://parabank.parasoft.com/");
    actor = new Actor("John");
  }

  @AfterAll
  static void tearDownWebDriver() {
    webDriver.quit();
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;

  public Actor(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;

  public Actor(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;
  private final WebDriver webDriver;

  public Actor(String name, WebDriver webDriver) {
    this.name = name;
    this.webDriver = webDriver
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;
  private final WebDriver webDriver;

  public Actor(String name, WebDriver webDriver) {
    this.name = name;
    this.webDriver = webDriver
  }
  
  public void login() {
    // ...
  }
  
  public void register() {
    // ...
  }
  
  public void transferFunds() {
    // ...
  }
  
  // ... (further task methods)

  @Override
  public String toString() {
    return this.name;
  }
}
public class BrowseTheWeb
    implements Abilitiy {

  private final WebDriver webDriver;

  public BrowseTheWeb(WebDriver webDriver) {
    this.webDriver = webDriver;
  }
  
  public WebDriver getWebDriver() {
    return webDriver;
  }
}
public interface Abilitiy {}
public class BrowseTheWeb
    implements Abilitiy {

  private final WebDriver webDriver;

  public BrowseTheWeb(WebDriver webDriver) {
    this.webDriver = webDriver;
  }
  
  public WebDriver getWebDriver() {
    return webDriver;
  }
  
  @Override
  public String toString() {
    return "browse the web with " +
      webDriver.getClass().getSimpleName();
  }
}
abstract public class BaseScreenplay {

  private static WebDriver webDriver;
  protected Actor user;
  protected String testUsername = "john";
  protected String testPassword = "demo";

  @BeforeAll
  static void setUpWebDriver() {
    WebDriverManager.chromiumdriver().setup();
    webDriver = new ChromeDriver();
  }

  @BeforeEach
  public void reset() {
    webDriver.manage().deleteAllCookies();
    webDriver.get("http://parabank.parasoft.com/");
    user = new Actor("John");
  }

  @AfterAll
  static void tearDownWebDriver() {
    webDriver.quit();
  }
}
abstract public class BaseScreenplay {

  private static WebDriver webDriver;
  protected Actor user;
  protected String testUsername = "john";
  protected String testPassword = "demo";

  @BeforeAll
  static void setUpWebDriver() {
    WebDriverManager.chromiumdriver().setup();
    webDriver = new ChromeDriver();
  }

  @BeforeEach
  public void reset() {
    webDriver.manage().deleteAllCookies();
    webDriver.get("http://parabank.parasoft.com/");
    user = new Actor("John")
        .can(new BrowseTheWeb(webDriver));
  }

  @AfterAll
  static void tearDownWebDriver() {
    webDriver.quit();
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;

  public Actor(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;

  public Actor(String name) {
    this.name = name;
  }
  
  public Actor can(Ability ability) {
    return this;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;
  private final Map<Class<? extends Ability>, Ability> abilities;

  public Actor(String name) {
    this.name = name;
  }
  
  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    return this;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  private static final Logger log =
      LoggerFactory.getLogger(Actor.class);

  private final String name;
  private final Map<Class<? extends Ability>, Ability> abilities;

  public Actor(String name) {
    this.name = name;
  }
  
  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }

  @Override
  public String toString() {
    return this.name;
  }
}
public class Actor {

  // ...
  
  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }
  
  public <A extends Ability> A uses(Class<A> abilityClass) {
    return null;
  }

  // ...
}
public class Actor {

  // ...
  
  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }
  
  public <A extends Ability> A uses(Class<A> abilityClass) {
    return abilities.get(abilityClass); 
  }

  // ...
}
public class Actor {

  // ...
  
  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }
  
  public <A extends Ability> A uses(Class<A> abilityClass) {
    return Optional.of(abilities.get(abilityClass))
        .map(abilityClass::cast); 
  }

  // ...
}
public class Actor {

  //...

  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }
  
  public <A extends Ability> A uses(Class<A> abilityClass) {
    return Optional.of(abilities.get(abilityClass))
        .map(abilityClass::cast)
        .orElseThrow(() ->
            new MissingAbilityException(this, abilityClass)
        );
  }

  //...
}
public class MissingAbilityException extends RuntimeException {

  public MissingAbilityException(
      Actor actor, Class<? extends Ability> abilityClass) {
    super(actor + " misses ability to " +
        abilityClass.getSimpleName());
  }
}
public class Actor {

  //...

  public Actor can(Ability ability) {
    abilities.put(ability.getClass(), ability);
    log.info(this + " can " + ability);
    return this;
  }
  
  public <A extends Ability> A uses(Class<A> abilityClass) {
    log.info(this + " uses " + abilityClass);
    return Optional.of(abilities.get(abilityClass))
        .map(abilityClass::cast)
        .orElseThrow(() ->
            new MissingAbilityException(this, abilityClass)
        );
  }

  //...
}

Screenplay

Actor

Abilities

Elements

Actions

enable

has

directs

interact with

Part 3: Performing Tasks

Screenplay

Actor

Abilities

Tasks

Elements

Actions

enable

has

directs

performs

made up of

interact with

public class Actor {

  //...

  public Actor does(Task task) {
    task.performedBy(this);
    log.info(this + " does " + task);
    return this;
  }

  //...
}
public interface Task {

  void performedBy(Actor actor);
}
public class HomePage extends Page {

  private static final By usernameInput =
      By.name("username");
  private static final By passwordInput =
      By.name("password");
  private static final By submitButton =
      By.cssSelector(".login input.button");

  // ...

  public void login(String username, String password) {
    webDriver
        .findElement(usernameInput)
        .sendKeys(username);
    webDriver
        .findElement(passwordInput)
        .sendKeys(password);
    webDriver
        .findElement(submitButton)
        .click();
  }
  
  // ...
}
public class Login implements Task {

  private final String username;
  private final String password;
  
  public Login(String username, String password) {
    this.username = username;
    this.password = password;
  }

  @Override
  public void performedBy(Actor actor) {
    final var webDriver = actor.uses(BrowseTheWeb.class)
        .getWebDriver();

    webDriver
        .findElement(By.name("username"))
        .sendKeys(username);
    webDriver
        .findElement(By.name("password"))
        .sendKeys(password);
    webDriver
        .findElement(By.cssSelector(".login input.button"))
        .click();
  }

  @Override
  public String toString() {
    return "login";
  }
}
public class LoginTest extends BaseTest {

  @Test
  public void canLogin() {
    homePage.login(testUsername, testPassword);

    assertTrue(homePage.isLoggedIn());
  }
}
public class LoginScreenplay extends BaseScreenplay {

  @Test
  public void canLogin() {
    user.does(new Login(testUsername, testPassword));

    // TODO assertTrue(...);
  }
}

Part 4: Answering Questions

Screenplay

Actor

Abilities

Tasks

Elements

Questions

Actions

enable

has

directs

asks

performs

made up of

interact with

about the state of

Part 5: Knowing/Learning Facts

Extension

Screenplay

Actor

Abilities

Tasks

Elements

Questions

Actions

enable

has

directs

asks

performs

made up of

interact with

about the state of

Facts

knows

req. for