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

Screenplay Design Pattern

By Michael Kutz

Screenplay Design Pattern

  • 1,164
Loading comments...

More from Michael Kutz