Что такое паттерн 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: Когда что выбирать
| Фактор | POM | Screenplay |
|---|---|---|
| Кривая обучения | Ниже | Выше |
| Небольшие проекты | Лучший выбор | Избыточен |
| Сложные сценарии | Может затрудняться | Превосходен |
| Мульти-системные тесты | Ограничен | Естественно подходит |
| Размер команды | Любой | Средний-большой |
| Читаемость | Хорошая | Отличная |
| Переиспользуемость | На уровне страницы | На уровне задачи |
Выбирайте 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:
- Определите Actor со способностью BrowseTheWeb
- Конвертируйте LoginPage.login() в Task Login
- Конвертируйте CartPage.addItem() в Task AddToCart
- Создайте Questions: CartTotal, ItemCount, OrderNumber
- Напишите составную задачу PlaceOrder
- Напишите 2 теста с подходом Screenplay
Сравните читаемость и гибкость обоих подходов.
Ключевые выводы
- Screenplay организует тесты вокруг Actor, Task, Question и Ability
- Тесты читаются как описания поведения на естественном языке
- Tasks компонуемы — сложные сценарии строятся из простых задач
- Screenplay превосходен в сложных мульти-системных проектах
- POM проще и лучше для простых веб-приложений
- Оба паттерна могут сосуществовать в одном проекте