Pular para o conteúdo principal

Screenplay Pattern com Playwright

O Screenplay Pattern é uma abordagem moderna e centrada no ator para escrever testes automatizados. Quando combinado com o Playwright, fornece uma forma poderosa e expressiva de escrever testes de automação de navegador que leem como documentação viva.

O módulo serenity-screenplay-playwright une:

  • Expressividade do Screenplay - Design de teste centrado no ator e baseado em tarefas
  • Confiabilidade do Playwright - Automação de navegador moderna com espera automática
  • Relatórios ricos do Serenity - Documentação passo a passo com capturas de tela

Por que Screenplay com Playwright?

O Screenplay Pattern oferece várias vantagens sobre abordagens tradicionais de Page Object:

Page ObjectScreenplay
Testes chamam métodos da página diretamenteActor executam Task e fazem Question
Estilo de código proceduralEstilo declarativo, focado em comportamento
Testes acoplados à estrutura da UITestes expressam intenção de negócio
Difícil compor açõesTask facilmente compostas em workflows

Com Playwright, você também obtém:

  • Espera automática incorporada em todas as interações
  • Asserções integradas com retry automático
  • Interceptação de rede para mock de APIs
  • Suporte a múltiplos navegadores (Chromium, Firefox, WebKit)

Configuração do Projeto

Dependências Maven

Adicione estas dependências ao seu pom.xml:

<properties>
<serenity.version>5.1.1</serenity.version>
<playwright.version>1.58.0</playwright.version>
</properties>

<dependencies>
<!-- Serenity Screenplay com Playwright -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-screenplay-playwright</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Serenity Screenplay Core -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-screenplay</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Serenity Ensure (para asserções fluentes) -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-ensure</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Serenity JUnit 5 -->
<dependency>
<groupId>net.serenity-bdd</groupId>
<artifactId>serenity-junit5</artifactId>
<version>${serenity.version}</version>
<scope>test</scope>
</dependency>

<!-- Playwright -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>${playwright.version}</version>
<scope>test</scope>
</dependency>

<!-- JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.1</version>
<scope>test</scope>
</dependency>
</dependencies>

Conceitos Principais

Actor e Ability

No Screenplay, os testes são escritos da perspectiva de Actor que possuem Ability. Para testes Playwright, os actor precisam da Ability BrowseTheWebWithPlaywright:

import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.playwright.abilities.BrowseTheWebWithPlaywright;

Actor toby = Actor.named("Toby");
toby.can(BrowseTheWebWithPlaywright.usingTheDefaultConfiguration());

A Ability gerencia o ciclo de vida do navegador Playwright automaticamente:

  • Cria o navegador, contexto e página sob demanda
  • Limpa automaticamente quando o teste termina (não é necessário teardown explícito)
  • Se inscreve nos eventos de ciclo de vida de teste do Serenity para gerenciamento de recursos sem fricção

Task

Task representam ações de alto nível que um ator executa. Elas expressam o que o ator faz em termos de negócio:

import net.serenitybdd.screenplay.Task;
import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.playwright.interactions.Open;
import net.serenitybdd.annotations.Step;

public class OpenTodoMvcApp implements Task {

@Override
@Step("{0} opens the TodoMVC application")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Open.url("https://todomvc.com/examples/react/dist/")
);
}

public static OpenTodoMvcApp onTheHomePage() {
return new OpenTodoMvcApp();
}
}

Task podem ser compostas de outras Task e Interaction:

public class AddATodoItem implements Task {
private final String todoItem;

public AddATodoItem(String todoItem) {
this.todoItem = todoItem;
}

@Override
@Step("{0} adds a todo item called '#todoItem'")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Enter.theValue(todoItem).into(TodoList.NEW_TODO_INPUT),
Press.keys("Enter")
);
}

public static AddATodoItem called(String todoItem) {
return new AddATodoItem(todoItem);
}
}

Question

Question permitem que os actor consultem o estado da aplicação:

import net.serenitybdd.screenplay.Question;
import net.serenitybdd.screenplay.playwright.abilities.BrowseTheWebWithPlaywright;

public class TheVisibleTodos {

public static Question<Collection<String>> displayed() {
return Question.about("the visible todos").answeredBy(
actor -> BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".todo-list li label")
.allTextContents()
);
}

public static Question<Integer> count() {
return Question.about("visible todo count").answeredBy(
actor -> BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".todo-list li")
.count()
);
}
}

Target

Target definem elementos de UI usando seletores do Playwright:

import net.serenitybdd.screenplay.playwright.Target;

public class TodoList {

public static final Target NEW_TODO_INPUT =
Target.the("new todo input")
.locatedBy("[placeholder='What needs to be done?']");

public static final Target TODO_ITEMS =
Target.the("todo items")
.locatedBy(".todo-list li");

public static Target todoItemCalled(String todoText) {
return Target.the("todo item '" + todoText + "'")
.locatedBy(".todo-list li:has-text('" + todoText + "')");
}

public static Target checkboxFor(String todoText) {
return Target.the("checkbox for '" + todoText + "'")
.locatedBy(".todo-list li:has-text('" + todoText + "') .toggle");
}
}

Target suportam:

  • Seletores parametrizados: Target.the("item {0}").locatedBy("[data-id='{0}']").of("123")
  • Target aninhados: button.inside(form)
  • Suporte a frame: Target.the("editor").inFrame("#iframe").locatedBy("#content")

Escrevendo Testes

Estrutura Básica de Teste

Aqui está um exemplo completo de um teste Screenplay com Playwright:

import net.serenitybdd.junit5.SerenityJUnit5Extension;
import net.serenitybdd.screenplay.Actor;
import net.serenitybdd.screenplay.ensure.Ensure;
import net.serenitybdd.screenplay.playwright.abilities.BrowseTheWebWithPlaywright;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("When adding todos")
class WhenAddingTodosTest {

Actor toby;

@BeforeEach
void setUp() {
toby = Actor.named("Toby");
toby.can(BrowseTheWebWithPlaywright.usingTheDefaultConfiguration());
}

// Não é necessário @AfterEach - a limpeza acontece automaticamente!

@Test
@DisplayName("should add a single todo item")
void shouldAddSingleTodoItem() {
toby.attemptsTo(
OpenTodoMvcApp.onTheHomePage(),
AddATodoItem.called("Buy milk"),
Ensure.that(TheVisibleTodos.count()).isEqualTo(1),
Ensure.that(TheTodoItem.called("Buy milk").exists()).isTrue()
);
}

@Test
@DisplayName("should add multiple todo items")
void shouldAddMultipleTodoItems() {
toby.attemptsTo(
OpenTodoMvcApp.onTheHomePage(),
AddATodoItem.called("Buy milk"),
AddATodoItem.called("Walk the dog"),
AddATodoItem.called("Do laundry"),
Ensure.that(TheVisibleTodos.displayed())
.containsExactly("Buy milk", "Walk the dog", "Do laundry")
);
}
}

Usando Ensure para Asserções

A classe Ensure fornece asserções fluentes que se integram com o modelo baseado em tarefas do Screenplay:

toby.attemptsTo(
// Comparações numéricas
Ensure.that(TheRemainingCount.value()).isEqualTo(3),
Ensure.that(TheVisibleTodos.count()).isGreaterThan(0),

// Verificações booleanas
Ensure.that(TheTodoItem.called("Buy milk").isCompleted()).isTrue(),
Ensure.that(TheClearCompletedButton.isVisible()).isFalse(),

// Comparações de string
Ensure.that(TheCurrentFilter.selected()).isEqualTo("All"),

// Asserções de coleção
Ensure.that(TheVisibleTodos.displayed())
.containsExactly("Buy milk", "Walk the dog"),
Ensure.that(TheVisibleTodos.displayed())
.contains("Buy milk")
);

Interaction Integradas

O módulo serenity-screenplay-playwright fornece muitas Interaction integradas:

// Abrir uma URL
actor.attemptsTo(Open.url("https://example.com"));

// Navegar para trás/frente
actor.attemptsTo(Navigate.back());
actor.attemptsTo(Navigate.forward());

// Recarregar a página
actor.attemptsTo(Navigate.refresh());

Clique

// Clique simples
actor.attemptsTo(Click.on(TodoList.SUBMIT_BUTTON));

// Duplo clique
actor.attemptsTo(DoubleClick.on(TodoList.TODO_LABEL));

// Clique direito
actor.attemptsTo(RightClick.on(TodoList.CONTEXT_MENU_TRIGGER));

Interações de Formulário

// Digitar texto
actor.attemptsTo(Enter.theValue("Hello").into(TodoList.INPUT_FIELD));

// Limpar um campo
actor.attemptsTo(Clear.field(TodoList.INPUT_FIELD));

// Pressionar teclas
actor.attemptsTo(Press.keys("Enter"));
actor.attemptsTo(Press.keys("Control+a"));

// Marcar/desmarcar checkboxes
actor.attemptsTo(Check.checkbox(TodoList.AGREE_CHECKBOX));
actor.attemptsTo(Uncheck.checkbox(TodoList.AGREE_CHECKBOX));

// Selecionar de dropdown
actor.attemptsTo(SelectFromOptions.byVisibleText("Option 1").from(TodoList.DROPDOWN));
actor.attemptsTo(SelectFromOptions.byValue("opt1").from(TodoList.DROPDOWN));

Ações de Mouse

// Passar o mouse sobre um elemento
actor.attemptsTo(Hover.over(TodoList.DELETE_BUTTON));

// Arrastar e soltar
actor.attemptsTo(Drag.from(SOURCE).to(TARGET));

// Rolar para visualização
actor.attemptsTo(ScrollIntoView.element(TodoList.FOOTER));

Espera

// Esperar por um elemento
actor.attemptsTo(WaitFor.the(TodoList.LOADING_SPINNER).toDisappear());

Execução de JavaScript

// Executar JavaScript
actor.attemptsTo(
ExecuteJavaScript.withScript("window.scrollTo(0, document.body.scrollHeight)")
);

Tratamento de Diálogos

// Aceitar alerta
actor.attemptsTo(HandleDialog.byAccepting());

// Dispensar alerta
actor.attemptsTo(HandleDialog.byDismissing());

// Digitar texto em prompt
actor.attemptsTo(HandleDialog.byEntering("my response"));

Asserções Específicas do Playwright

A classe Ensure também fornece asserções específicas do Playwright com auto-retry:

import net.serenitybdd.screenplay.playwright.assertions.Ensure;

actor.attemptsTo(
// Visibilidade de elemento
Ensure.that(TodoList.MAIN_SECTION).isVisible(),
Ensure.that(TodoList.LOADING_SPINNER).isHidden(),

// Conteúdo de texto
Ensure.that(TodoList.HEADER).hasText("todos"),
Ensure.that(TodoList.HEADER).containsText("todo"),

// Contagem de elementos
Ensure.that(TodoList.TODO_ITEMS).hasCount(3),

// Atributos e classes
Ensure.that(TodoList.TODO_ITEM).hasClass("completed"),
Ensure.that(TodoList.INPUT).hasAttribute("placeholder", "What needs to be done?"),

// Estado do elemento
Ensure.that(TodoList.SUBMIT_BUTTON).isEnabled(),
Ensure.that(TodoList.CHECKBOX).isChecked(),

// URL e título da página
Ensure.thatTheCurrentUrl().contains("/dashboard"),
Ensure.thatThePageTitle().isEqualTo("My App")
);

Recursos Avançados

Acesso Direto à API do Playwright

Para cenários avançados, você pode acessar a API do Playwright diretamente:

Page page = BrowseTheWebWithPlaywright.as(actor).getCurrentPage();

// Use a API nativa do Playwright
page.locator(".my-element").click();

// Acesse o contexto do navegador
BrowserContext context = page.context();

// Interceptação de rede
page.route("**/api/**", route -> {
route.fulfill(new Route.FulfillOptions()
.setBody("{\"mocked\": true}")
.setContentType("application/json"));
});

Task Personalizadas

Crie Task específicas do domínio para sua aplicação:

public class CompleteCheckout implements Task {

private final PaymentDetails paymentDetails;

public CompleteCheckout(PaymentDetails paymentDetails) {
this.paymentDetails = paymentDetails;
}

@Override
@Step("{0} completes checkout with #paymentDetails")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
EnterShippingAddress.forCurrentUser(),
EnterPaymentDetails.using(paymentDetails),
Click.on(CheckoutPage.PLACE_ORDER_BUTTON),
WaitFor.the(CheckoutPage.CONFIRMATION_MESSAGE).toAppear()
);
}

public static CompleteCheckout using(PaymentDetails details) {
return new CompleteCheckout(details);
}
}

Question Personalizadas

Crie Question reutilizáveis para seu domínio:

public class TheOrderTotal {

public static Question<BigDecimal> displayed() {
return Question.about("the order total").answeredBy(actor -> {
String totalText = BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".order-total")
.textContent();
return new BigDecimal(totalText.replace("$", ""));
});
}
}

// Uso
actor.attemptsTo(
Ensure.that(TheOrderTotal.displayed()).isGreaterThan(new BigDecimal("100.00"))
);

Múltiplas Páginas/Abas

Lide com múltiplas abas do navegador:

// Abrir uma nova página
BrowseTheWebWithPlaywright.as(actor).openNewPage();

// Alternar entre páginas
BrowseTheWebWithPlaywright.as(actor).switchToPage(0);
BrowseTheWebWithPlaywright.as(actor).switchToPageWithTitle("Dashboard");

// Fechar página atual
BrowseTheWebWithPlaywright.as(actor).closeCurrentPage();

Gerenciamento de Cookies

Gerencie cookies do navegador:

// Obter todos os cookies
List<Cookie> cookies = BrowseTheWebWithPlaywright.as(actor).getCookies();

// Adicionar um cookie
Cookie cookie = new Cookie("session", "abc123")
.setDomain("example.com")
.setPath("/");
BrowseTheWebWithPlaywright.as(actor).addCookie(cookie);

// Limpar cookies
BrowseTheWebWithPlaywright.as(actor).clearCookies();

Tratamento de Frames

Interaja com elementos dentro de iframes:

// Defina um target dentro de um frame
Target EDITOR_CONTENT = Target.the("editor content")
.inFrame("#editor-iframe")
.locatedBy("#content");

// Interaja com ele normalmente
actor.attemptsTo(
Enter.theValue("Hello World").into(EDITOR_CONTENT)
);

Capturas de Tela

Faça capturas de tela explicitamente:

// O método notifyScreenChange() captura uma tela
BrowseTheWebWithPlaywright.as(actor).notifyScreenChange();

// Ou faça uma captura de tela explicitamente
ScreenshotAndHtmlSource screenshot = BrowseTheWebWithPlaywright.as(actor).takeScreenShot();

Configuração

Opções do Navegador

Configure opções do navegador Playwright via propriedades do sistema ou programaticamente:

# serenity.properties
playwright.browsertype=chromium
playwright.headless=true
playwright.slowMo=100

Ou programaticamente:

actor.can(
BrowseTheWebWithPlaywright
.withOptions(new BrowserType.LaunchOptions().setHeadless(false))
.withBrowserType("firefox")
);

Configuração de Captura de Tela

Controle a captura de telas:

# serenity.conf
serenity {
take.screenshots = FOR_EACH_ACTION # ou FOR_FAILURES, DISABLED
}

Exemplo Completo: Suíte de Testes TodoMVC

Aqui está um exemplo abrangente mostrando todos os conceitos juntos:

Target de UI

public class TodoList {

public static final Target NEW_TODO_INPUT =
Target.the("new todo input")
.locatedBy("[placeholder='What needs to be done?']");

public static final Target TODO_ITEMS =
Target.the("todo items")
.locatedBy(".todo-list li");

public static final Target TODO_COUNT =
Target.the("todo count")
.locatedBy(".todo-count");

public static final Target CLEAR_COMPLETED_BUTTON =
Target.the("clear completed button")
.locatedBy(".clear-completed");

public static final Target ALL_FILTER =
Target.the("All filter")
.locatedBy(".filters a:has-text('All')");

public static final Target ACTIVE_FILTER =
Target.the("Active filter")
.locatedBy(".filters a:has-text('Active')");

public static final Target COMPLETED_FILTER =
Target.the("Completed filter")
.locatedBy(".filters a:has-text('Completed')");

public static Target todoItemCalled(String text) {
return Target.the("todo '" + text + "'")
.locatedBy(".todo-list li:has-text('" + text + "')");
}

public static Target checkboxFor(String text) {
return Target.the("checkbox for '" + text + "'")
.locatedBy(".todo-list li:has-text('" + text + "') .toggle");
}

public static Target deleteButtonFor(String text) {
return Target.the("delete button for '" + text + "'")
.locatedBy(".todo-list li:has-text('" + text + "') .destroy");
}
}

Task

// AddATodoItem.java
public class AddATodoItem implements Task {
private final List<String> items;

public AddATodoItem(List<String> items) {
this.items = items;
}

@Override
@Step("{0} adds todo items: #items")
public <T extends Actor> void performAs(T actor) {
for (String item : items) {
actor.attemptsTo(
Enter.theValue(item).into(TodoList.NEW_TODO_INPUT),
Press.keys("Enter")
);
}
}

public static AddATodoItem called(String item) {
return new AddATodoItem(List.of(item));
}

public static AddATodoItem withItems(String... items) {
return new AddATodoItem(Arrays.asList(items));
}
}

// Complete.java
public class Complete implements Task {
private final String todoItem;

public Complete(String todoItem) {
this.todoItem = todoItem;
}

@Override
@Step("{0} completes the todo item '#todoItem'")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Click.on(TodoList.checkboxFor(todoItem))
);
}

public static Complete todoItem(String item) {
return new Complete(item);
}
}

// Delete.java
public class Delete implements Task {
private final String todoItem;

public Delete(String todoItem) {
this.todoItem = todoItem;
}

@Override
@Step("{0} deletes the todo item '#todoItem'")
public <T extends Actor> void performAs(T actor) {
actor.attemptsTo(
Hover.over(TodoList.todoItemCalled(todoItem)),
Click.on(TodoList.deleteButtonFor(todoItem))
);
}

public static Delete theTodoItem(String item) {
return new Delete(item);
}
}

// FilterTodos.java
public class FilterTodos {

public static Task toShowAll() {
return Task.where("{0} filters to show all todos",
Click.on(TodoList.ALL_FILTER)
);
}

public static Task toShowActive() {
return Task.where("{0} filters to show active todos",
Click.on(TodoList.ACTIVE_FILTER)
);
}

public static Task toShowCompleted() {
return Task.where("{0} filters to show completed todos",
Click.on(TodoList.COMPLETED_FILTER)
);
}
}

Question

// TheVisibleTodos.java
public class TheVisibleTodos {

public static Question<Collection<String>> displayed() {
return Question.about("the visible todos").answeredBy(
actor -> BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".todo-list li label")
.allTextContents()
);
}

public static Question<Integer> count() {
return Question.about("visible todo count").answeredBy(
actor -> BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".todo-list li")
.count()
);
}
}

// TheRemainingCount.java
public class TheRemainingCount {

public static Question<Integer> value() {
return Question.about("the remaining count").answeredBy(actor -> {
String text = BrowseTheWebWithPlaywright.as(actor)
.getCurrentPage()
.locator(".todo-count")
.textContent();
Matcher matcher = Pattern.compile("(\\d+)").matcher(text);
return matcher.find() ? Integer.parseInt(matcher.group(1)) : 0;
});
}
}

Classe de Teste

@ExtendWith(SerenityJUnit5Extension.class)
@DisplayName("When managing todos")
class WhenManagingTodosTest {

Actor toby;

@BeforeEach
void setUp() {
toby = Actor.named("Toby");
toby.can(BrowseTheWebWithPlaywright.usingTheDefaultConfiguration());
}

// Não é necessário @AfterEach - BrowseTheWebWithPlaywright limpa
// automaticamente os recursos do navegador quando o teste termina

@Test
@DisplayName("should add and complete todos")
void shouldAddAndCompleteTodos() {
toby.attemptsTo(
Open.url("https://todomvc.com/examples/react/dist/"),
AddATodoItem.withItems("Buy milk", "Walk the dog", "Do laundry"),

Ensure.that(TheVisibleTodos.count()).isEqualTo(3),
Ensure.that(TheRemainingCount.value()).isEqualTo(3),

Complete.todoItem("Buy milk"),

Ensure.that(TheRemainingCount.value()).isEqualTo(2),

FilterTodos.toShowCompleted(),

Ensure.that(TheVisibleTodos.displayed()).containsExactly("Buy milk"),

FilterTodos.toShowActive(),

Ensure.that(TheVisibleTodos.displayed())
.containsExactly("Walk the dog", "Do laundry")
);
}

@Test
@DisplayName("should delete a todo item")
void shouldDeleteTodoItem() {
toby.attemptsTo(
Open.url("https://todomvc.com/examples/react/dist/"),
AddATodoItem.withItems("Item 1", "Item 2", "Item 3"),

Delete.theTodoItem("Item 2"),

Ensure.that(TheVisibleTodos.displayed())
.containsExactly("Item 1", "Item 3")
);
}
}

Boas Práticas

1. Mantenha Task Focadas em Negócio

Task devem expressar o que o usuário está tentando alcançar, não como eles alcançam:

// Bom - expressa intenção
actor.attemptsTo(
AddItemToCart.called("Blue T-Shirt"),
ProceedToCheckout.withStandardShipping()
);

// Evite - muito técnico
actor.attemptsTo(
Click.on(ProductPage.ADD_TO_CART_BUTTON),
Click.on(Header.CART_ICON),
Click.on(CartPage.CHECKOUT_BUTTON)
);

2. Use Nomes de Target Significativos

Os nomes dos Target aparecem nos relatórios, então seja descritivo:

// Bom
Target SUBMIT_ORDER_BUTTON = Target.the("submit order button")
.locatedBy("#checkout-submit");

// Evite
Target BUTTON = Target.the("button").locatedBy("#checkout-submit");

3. Crie Question Específicas do Domínio

Question devem retornar objetos de domínio significativos:

// Bom
Question<Money> orderTotal = TheOrderTotal.displayed();
Question<List<CartItem>> cartItems = TheCartContents.items();

// Evite
Question<String> total = Text.of(OrderPage.TOTAL);

4. Aproveite Ensure para Asserções Legíveis

Use Ensure.that() para asserções que leem naturalmente:

actor.attemptsTo(
Ensure.that(TheOrderStatus.displayed()).isEqualTo(OrderStatus.CONFIRMED),
Ensure.that(TheDeliveryDate.shown()).isAfter(LocalDate.now())
);

Próximos Passos