Что такое паттерн Screenplay?

Паттерн Screenplay — продвинутая альтернатива Page Object Model. Вместо организации тестов вокруг страниц он строит их вокруг актёров, выполняющих задачи и задающих вопросы с помощью своих способностей.

Представьте это как сценарий фильма — вы описываете, что делают актёры, а не как выглядит UI.

POM vs Screenplay: Сравнение

Подход POM:

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:

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

Версия Screenplay читается больше как описание поведения пользователя на естественном языке.

Четыре строительных блока

1. Актёры (Actors)

Actor представляет пользователя системы с определёнными способностями и контекстом.

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);
  }
}

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

2. Способности (Abilities)

Abilities определяют, что может делать Actor — взаимодействовать с браузером, вызывать API, обращаться к БД.

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 представляют высокоуровневые действия пользователя. Они компонуемы — сложные задачи строятся из простых.

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 позволяют актёрам запрашивать состояние системы.

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);
  }
}

Написание тестов с Screenplay

test('покупатель может добавить товары в корзину и оформить заказ', async ({ page }) => {
  const customer = new Actor('Customer')
    .can(BrowseTheWeb.using(page));

  await customer.attemptsTo(
    Login.withCredentials('customer@test.com', 'password'),
    AddProductToCart.called('Беспроводная мышь'),
    AddProductToCart.called('USB-клавиатура')
  );

  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+$/);
});

Композиция сложных задач

Одна из сильных сторон Screenplay — композиция задач:

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)
    );
  }
}

// Высокоуровневый тест
test('полный процесс покупки', async ({ page }) => {
  const customer = new Actor('Customer').can(BrowseTheWeb.using(page));
  await customer.attemptsTo(
    Login.withCredentials('customer@test.com', 'password'),
    PlaceOrder.for('Беспроводная мышь').shippingTo(homeAddress).payingWith(visa)
  );
});

Screenplay vs POM: Когда что выбирать

ФакторPOMScreenplay
Кривая обученияНижеВыше
Небольшие проектыЛучший выборИзбыточен
Сложные сценарииМожет затруднятьсяПревосходен
Мульти-системные тестыОграниченЕстественно подходит
Размер командыЛюбойСредний-большой
ЧитаемостьХорошаяОтличная
ПереиспользуемостьНа уровне страницыНа уровне задачи

Выбирайте POM, когда:

  • Приложение имеет чётко определённые страницы
  • Команда маленькая или новая в автоматизации
  • Тесты — в основном взаимодействия на одной странице
  • Нужно быстро начать

Выбирайте Screenplay, когда:

  • Сценарии охватывают множество страниц и систем
  • Нужна максимальная переиспользуемость между разными наборами тестов
  • Команда опытная и справится с дополнительной сложностью
  • Тесты вовлекают несколько пользовательских ролей

Serenity/JS: Screenplay на практике

Serenity/JS — популярный фреймворк, реализующий паттерн Screenplay:

import { actorCalled } 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')
);

Упражнение: Конвертируйте POM в Screenplay

Возьмите POM для e-commerce из Урока 8.8 и рефакторите в паттерн Screenplay:

  1. Определите Actor со способностью BrowseTheWeb
  2. Конвертируйте LoginPage.login() в Task Login
  3. Конвертируйте CartPage.addItem() в Task AddToCart
  4. Создайте Questions: CartTotal, ItemCount, OrderNumber
  5. Напишите составную задачу PlaceOrder
  6. Напишите 2 теста с подходом Screenplay

Сравните читаемость и гибкость обоих подходов.

Ключевые выводы

  • Screenplay организует тесты вокруг Actor, Task, Question и Ability
  • Тесты читаются как описания поведения на естественном языке
  • Tasks компонуемы — сложные сценарии строятся из простых задач
  • Screenplay превосходен в сложных мульти-системных проектах
  • POM проще и лучше для простых веб-приложений
  • Оба паттерна могут сосуществовать в одном проекте