Abandone Page Objects e comece a aplicar App Actions

O texto abaixo é a uma tradução do artigo escrito pelo Gleb Bahmutov. Para acessar a versão original em inglês, clique aqui. Caso você tenha sugestões para tornar a tradução melhor, compartilhe através da seção de comentários no final da página.

The below text is a translation of an article written by Gleb Bahmutov. To access the original English version, click here. If you have suggestions for making the translation better, share it through the comments section at the bottom of the page.

Escrever testes sustentáveis de ponta a ponta ​​é um desafio. Frequentemente, testadores criam outra camada de abstração com base na página web chamada de Page Objects, para a execução de ações comuns. Neste post, defendo que os Page Objects são uma má prática e sugiro o envio de ações diretamente para a lógica interna da aplicação. Isso funciona muito bem com o moderno runner de testes do Cypress.io, que executa o código de teste diretamente ao lado do código da aplicação.

O que são Page objects?

Page Objects 1, 2 têm como objetivo tornar os testes de ponta a ponta legíveis e fáceis de manter. Em vez de interações ad-hoc com uma página, um teste controla a página usando uma instância que representa a interface do usuário de tal página. Por exemplo, aqui está uma abstração de uma de página de login obtida diretamente da página Wiki do Selenium .

public class LoginPage {
  private final WebDriver driver;

  public LoginPage(WebDriver driver) {
    this.driver = driver;

    // Verifica se estamos na página certa.
    if (!"Login".equals(driver.getTitle())) {
      // Alternatively, we could navigate to the
      // login page, perhaps logging out first
      throw new IllegalStateException("This is not the login page");
    }
  }

  // A página de login contém vários elementos HTML
  // Que serão representados como WebElements.
  // Os localizadores para esses elementos devem ser definidos apenas uma vez..
  By usernameLocator = By.id("username");
  By passwordLocator = By.id("password");
  By loginButtonLocator = By.id("login");

  // A página de login permite que o usuário digite seu
  // nome de usuário no campo nome de usuário
  public LoginPage typeUsername(String username) {
    // Este é o único lugar que "sabe" como inserir um nome de usuário
    driver.findElement(usernameLocator).sendKeys(username);

    // Retorne o objeto de página atual, pois esta ação não
    // navegar até uma página representada por outro PageObject
    return this;
  }
  // outros métodos
  // - typePassword
  // - submitLogin
  // - submitLoginExpectingFailure
  // - loginAs
}

Page objects têm dois benefícios principais:

  1. Eles mantêm todos os seletores de elementos da página em um só lugar
  2. Eles padronizam como os testes interagem com a página

Um teste típico usaria Page objects assim, por exemplo:

public void testLogin() {
  LoginPage login = new LoginPage(driver);
  login.typeUsername('username')
  login.typePassword('username')
  login.submitLogin()
}

Martin Fowler em seu post PageObject descreve Page Objects como outra API no topo do HTML. Conceitualmente, ele está acima do HTML.

Tests
-----------------
  Page Objects
~ ~ ~ ~ ~ ~ ~ ~ ~
    HTML UI
-----------------
Application code

Application code

Os 4 níveis no diagrama acima têm 3 interfaces de acoplamento diferente.

  1. O código da aplicação para HTML é restrito
  2. O HTML para o Page object é muito flexível
  3. Os testes com relação aos Page Object são restritos

O acoplamento do código da aplicação a para a UI do HTML é muito rígida,pois o código renderiza os elementos HTML no DOM – existe um relacionamento um para um entre a função render no código e os elementos de saída no DOM.Tipos estáticos e linters ajudam a garantir que o código da aplicação seja consistente e o produza HTML corretamente.

Tipos estáticos e linters ajudam a garantir que o código da aplicação é consistente e gera HTML com significado.

Page Objects são fracamente acoplados com relação ao HTML, por isso desenhei o limite usando caracteres ~ ~. Eles usam seletores para encontrar elementos, que NÃO são verificados por nenhum linter ou compilador de código! O código da aplicação pode mudar a qualquer momento, gerar uma estrutura DOM diferente ou classes de elementos diferentes, e os testes irão quebrar em tempo de execução sem nenhum aviso.

💡Somente por fins de curiosidade, isso é algo que temos implementado no GitLab.

Por fim, os Page Objects são restritos com relação aos testes  – pois ambos os níveis estão no mesmo código e podem ser verificados pelo compilador para encontrar erros do(a) programador(a).

Page objects no Cypress

Você pode facilmente usar Page Objects nos testes com Cypress. Aqui está um exemplo típico no blog post,  Deep diving PageObject pattern and using it with Cypress. Uma classe Page Object típica chamada de SignInPage é semelhante à LoginPage do Selenium mostrada acima.

class SignInPage {
  visit() {
    cy.visit('/signin');
  }

  getEmailError() {
    return cy.get(`[data-testid=SignInEmailError]`);
  }

  getPasswordError() {
    return cy.get(`[data-testid=SignInPasswordError]`);
  }

  fillEmail(value) {
    const field = cy.get(`[data-testid=SignInEmailField]`);
    field.clear();
    field.type(value);

    return this;
  }

  fillPassword(value) {
    const field = cy.get(`[data-testid=SignInPasswordField]`);
    field.clear();
    field.type(value);

    return this;
  }

  submit() {
    const button = cy.get(`[data-testid=SignInSubmitButton]`);
    button.click();
  }
}

export default SignInPage;

Ao escrever um teste para a “Home page”, podemos reutilizar o SignInPage de outro Page Object

import Header from './Headers';
import SignInPage from './SignIn';

class HomePage {
  constructor() {
    this.header = new Header();
  }

  visit() {
    cy.visit('/');
  }

  getUserAvatar() {
    return cy.get(`[data-testid=UserAvatar]`);
  }

  goToSignIn() {
    const link = this.header.getSignInLink();
    link.click();

    const signIn = new SignInPage();
    return signIn;
  }
}

export default HomePage;

Esse é um cenário típico – você precisa escrever uma hierarquia de classes Page Object inteira, em que partes da página estão usando Page Objects diferentes, sendo composta por um design orientado a objetos. Um teste típico se parece com isso.

import HomePage from '../elements/pages/HomePage';

describe('Sign In', () => {
  it('should show an error message on empty input', () => {
    const home = new HomePage();
    home.visit();

    const signIn = home.goToSignIn();

    signIn.submit();

    signIn.getEmailError()
      .should('exist')
      .contains('Email is required');

    signIn
      .getPasswordError()
      .should('exist')
      .contains('Password is required');
  });

  // mais testes
});

O Cypress vem com um bundler JavaScript incluído ; Portanto, o código acima simplesmente funciona.

Você não precisa usar a implementação Page Object orientada a objeto. Você também pode mover a lógica típica para comandos reutilizáveis do Cypress Custom Commands que não possuem nenhum estado interno e apenas permitem a reutilização de código. Por exemplo, você pode implementar um comando de “login”.

// in cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
  cy.get('#login-username').type(username)
  cy.get('#login-password').type(password)
  cy.get('#login').submit()
})

Após adicionar um comando customizado, os testes podem usá-lo como qualquer comando interno.

// cypress/integration/spec.js
it('logs in', () => {
  cy.visit('/login')
  cy.login('username', 'password')
})

Observe que você não precisa sempre criar comandos customizados e funções simples de JavaScript funcionam da mesma forma (se não melhor, pois a etapa de verificação de tipo pode entender assinaturas de funções individuais).

// cypress/integration/util.js
export const login = (username, password) => {
  cy.get('#login-username').type(username)
  cy.get('#login-password').type(password)
  cy.get('#login').submit()
}

// cypress/integration/spec.js
import { login } from './util'

it('logs in', () => {
  cy.visit('/login')
  login('username', 'password')
})

Problemas dos Page Objects

Nas próximas seções, examinarei exemplos concretos em que o padrão Page Object fica aquém do que precisamos para escrever bons testes ponta-a-ponta.

  • PageObjects são difíceis de manter e tomam tempo do desenvolvimento real da aplicação. Nunca vi Page Objects documentados o suficiente para realmente ajudar a escrever testes.
  • Page Objects introduzem estado adicional nos testes, que é separado do estado interno da aplicação. Isso dificulta a compreensão dos testes e falhas.
  • Page Objects tentam encaixar vários casos em uma interface uniforme, voltando à lógica condicional – um enorme  anti-pattern em nossa opinião
  • Page Objects tornam os testes lentos porque forçam os testes a sempre passarem pela interface do usuário da aplicação.

Não se desespere! Também mostrarei uma alternativa aos  Page Objects que chamo de “App Action” que nossos testes de ponta a ponta podem usar. Acredito que App actions resolvem os problemas acima muito bem, tornando os testes de ponta-a-ponta rápidos e produtivos.

Exemplo de adição de itens

Vamos pegar os testes do TodoMVC como exemplo. Primeiro, testaremos se o usuário pode inserir todos (itens a fazer em inglês). Usaremos o Cypress para fazer isso através da interface de usuário, assim como um usuário real digitaria itens.

describe('TodoMVC', function () {
  // set up these constants to match what TodoMVC does
  let TODO_ITEM_ONE = 'buy some cheese'
  let TODO_ITEM_TWO = 'feed the cat'
  let TODO_ITEM_THREE = 'book a doctors appointment'

  beforeEach(function () {
    cy.visit('/')
  })

  context('New Todo', function () {
    it('should allow me to add todo items', function () {
      cy.get('.new-todo').type(TODO_ITEM_ONE).type('{enter}')
      cy.get('.todo-list li').eq(0).find('label').should('contain', TODO_ITEM_ONE)
      cy.get('.new-todo').type(TODO_ITEM_TWO).type('{enter}')
      cy.get('.todo-list li').eq(1).find('label').should('contain', TODO_ITEM_TWO)
    })
    // mais testes para adicionar itens
    // - adiciona itens
    // - deve limpar o campo de entrada de texto quando um item é adicionado
    // - deve acrescentar novos itens ao final da lista
    // - deve cortar a entrada de texto
    // - deve mostrar #main e #footer quando os itens são adicionados
  })
})

Todos esses testes dentro do bloco “New Todo” inserem itens usando o elemento <input class = “new-todo” /> sem usar nenhum atalho. Aqui estão esses testes sendo executados sozinhos.

Todo teste começa digitando “buy some cheese” e alguns outros itens, assim como um usuário que é amante de de queijo faria.

Exemplo de itens concluídos

Agora, vamos testar a funcionalidade da aplicação de “marcar todos os itens como concluídos”. O usuário pode clicar em um elemento em nossa aplicação com um markup para marcar todos os itens atuais como concluídos.

<input
  className='toggle-all'
  type='checkbox'
  onChange={this.toggleAll}
  checked={activeTodoCount === 0} />

Aqui está a pergunta de um milhão de dólares – como inserimos itens de tarefas antes de clicar em .toggle-all? Poderíamos escrever e usar um comando customizado como cy.createDefaultTodos().as(‘todos’) para percorrer a interface de usuário da página, basicamente manipulando a página para criar itens.

// cypress/support/commands.js
const TODO_ITEM_ONE = 'buy some cheese'
const TODO_ITEM_TWO = 'feed the cat'
const TODO_ITEM_THREE = 'book a doctors appointment'

Cypress.Commands.add('createDefaultTodos', function () {
  cy.get('.new-todo')
    .type(`${TODO_ITEM_ONE}{enter}`)
    .type(`${TODO_ITEM_TWO}{enter}`)
    .type(`${TODO_ITEM_THREE}{enter}`)
    .get('.todo-list li')
})

Criaremos esse novo comando customizados createDefaultTodos antes de cada teste no bloco beforeEach.

// cypress/integration/spec.js
context('Mark all as completed', function () {
  beforeEach(function () {
    cy.createDefaultTodos().as('todos')
  })

  it('should allow me to mark all items as completed', function () {
    // complete all todos
    // we use 'check' instead of 'click'
    // because that indicates our intention much clearer
    cy.get('.toggle-all').check()

    // get each todo li and ensure its class is 'completed'
    cy.get('@todos').eq(0).should('have.class', 'completed')
    cy.get('@todos').eq(1).should('have.class', 'completed')
    cy.get('@todos').eq(2).should('have.class', 'completed')
  })
 // mais testes
 // - deve permitir-me limpar o estado completo de todos os itens
 // - a caixa de seleção completa deve atualizar o estado quando os itens são concluídos / limpos
})

Aqui está o primeiro teste por si só:

Mas, considere duas coisas:

  1. Estamos sempre inserindo os itens pela interface do usuário – repetindo o que todo teste no contexto “New Todo” já fez.
  1. A maior parte do tempo de execução do teste foi ocupada pela inserção dos itens e não por marcar o item como completo.

O último ponto é importante – nossos testes são lentos devido a inserção de 3 itens pela interface do usuário antes de cada teste. Os três testes no contexto acima “Mark all as completed” geralmente levam de 4 a 5 segundos.

Application actions

Código Fonte

Você pode encontrar o código fonte final deste blog post no passo-a-passo das  App Actions. Você também pode ver os mesmos testes implementados em estilos diferentes, incluindo Page Object e App Actions no repositório bahmutov/test-todomvc-using-app-actions.

Imagine que, em vez de sempre inserir novos itens por meio da interface do usuário, possamos definir o estado do aplicativo diretamente por nosso teste. Como a arquitetura do Cypress permite interagir com o aplicativo em teste, isso é simples. Tudo o que precisamos fazer é expor uma referência ao objeto de modelo da aplicação, anexando-o ao objeto window, por exemplo.

// app.jsx code
var model = new app.TodoModel('react-todos');

if (window.Cypress) {
  window.model = model
}

Definir um modelo de referência como uma propriedade do objeto window  da aplicação fornece aos nossos testes uma maneira fácil de chamar um método model.addTodo que já existe em js/todoModel.js

// js/todoModel.js
// Modelo: mantém todos os "todos" e tem métodos para agir sobre eles
app.TodoModel = function(key) {
  this.key = key
  this.todos = Utils.store(key)
  this.onChanges = []
}
app.TodoModel.prototype.addTodo = function(title) {
  this.todos = this.todos.concat({
    id: Utils.uuid(),
    title: title,
    completed: false
  });

  this.inform();
};
app.TodoModel.prototype.inform = ...
app.TodoModel.prototype.toggleAll = ...
// outros métodos

Em vez de usar um comando cutomizado do Page Object o para criar todos como cy.createDefaultTodos().as(‘todos’), podemos usar model.addTodo para adicionar itens diretamente usando a “API” interna da aplicação. No código abaixo, estou usando cy.window () para acessar o object window da aplicação, e seu modelo de propriedade, em seguida, .invoke() para chamar o método addTodo na instância do modelo.

beforeEach(function() {
  cy.window().its('model').invoke('addTodo', TODO_ITEM_ONE)
  cy.window().its('model').invoke('addTodo', TODO_ITEM_TWO)
  cy.window().its('model').invoke('addTodo', TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
})

Com a configuração acima, nossos testes estão sendo executados muito mais rápido – os três terminam em pouco mais de 1 segundo, já três vezes mais rápido do que antes. Mas, mesmo o código acima é mais lento que o necessário – porque estamos usando vários comandos do Cypress para adicionar cada item, o que gera sobrecarga. Em vez disso, podemos alterar o TodoModel.prototype.addTodo para aceitar vários itens de uma só vez.

// js/todoModel.js
app.TodoModel.prototype.addTodo = function(...titles) {
  titles.forEach(title => {
    this.todos = this.todos.concat({
      id: Utils.uuid(),
      title: title,
      completed: false
    });
  })

  this.inform();
};

// cypress/integration/spec.js
beforeEach(function () {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
})

Você percebeu o que fizemos para melhorar nosso teste? NÃO alteramos o código de teste, mas melhoramos o código da aplicação. Ao usar as ações internas da aplicação em nossos testes de ponta a ponta, podemos melhorar a aplicação enquanto escrevemos nossos testes! Nosso esforço leva diretamente a esclarecer o modelo de interface, tornando-o mais testável, melhor documentado e facilitando o uso de outro código da aplicação.

Você também pode invocar app action da aplicação no console do DevTools diretamente, alternando o contexto para “Your app”, veja a captura de tela abaixo.

Apenas funções

Podemos mover a lógica de app action para comandos customizados, substituindo o uso da interface do usuário para manipular o estado pela interface do modelo interno da aplicação que está chamando. Mas, prefiro criar pequenas funções reutilizáveis, em vez de anexar métodos adicionais ao objeto cy.

const addDefaultTodos = () => {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
}

beforeEach(addDefaultTodos)

Como o Cypress vem com um bundler incluído, podemos mover addDefaultTodos para um arquivo separado com utilitários e usar diretivas de solicitação ou importação para usá-lo a partir do arquivo de especificação (o arquivo de testes). E podemos documentar addDefaultTodos usando a convenção JSDoc para obter uma bela funcionalidade de autocomplete em nossos arquivos de teste.

// utils.js
const TODO_ITEM_ONE = 'buy some cheese'
const TODO_ITEM_TWO = 'feed the cat'
const TODO_ITEM_THREE = 'book a doctors appointment'

/**
 * Creates default todo items using application action.
 * @example
 *  import { addDefaultTodos } from './utils'
 *  beforeEach(addDefaultTodos)
 */
export const addDefaultTodos = () => {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
}

Usar app action é apenas o uso de funções JavaScript, e o uso de funções é simples.

Exemplo de persistência

Há outro exemplo nos testes da aplicação TodoMVC que mostra o poder de definir o estado e as ações iniciais. O teste de persistência adiciona dois itens, clica em um deles e recarrega a página. Os dois itens devem estar lá e o estado concluído deve ser preservado. O teste original no Cypress faz tudo através da interface do usuário.

context('Persistence', function() {
  it('should persist its data', function() {
    // mimicking TodoMVC tests
    // by writing out this function
    function testState() {
      cy.get('@firstTodo').should('contain', TODO_ITEM_ONE)
        .and('have.class', 'completed')
      cy.get('@secondTodo').should('contain', TODO_ITEM_TWO)
        .and('not.have.class', 'completed')
    }

    cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
    cy.createTodo(TODO_ITEM_TWO).as('secondTodo')
    cy.get('@firstTodo').find('.toggle').check()
    .then(testState)

    .reload()
    .then(testState)
  })
})

A função auxiliar testState verifica os dois itens – o primeiro deve ser concluído e o segundo não. Verificamos antes de recarregar a página e depois.

Mas por que estamos criando itens e por que estamos clicando nos primeiros itens para marcá-los como completos? Nós sabemos que funciona! Temos outro teste acima que já testou a interface do usuário para concluir um item. Esse teste foi chamado de item – deve permitir que eu marque os itens como concluídos e parece quase exatamente o mesmo:

context('Item', function() {
  it('should allow me to mark items as complete', function() {
    cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
    cy.createTodo(TODO_ITEM_TWO).as('secondTodo')

    cy.get('@firstTodo').find('.toggle').check()
    cy.get('@firstTodo').should('have.class', 'completed')

    cy.get('@secondTodo').should('not.have.class', 'completed')
    cy.get('@secondTodo').find('.toggle').check()

    cy.get('@firstTodo').should('have.class', 'completed')
    cy.get('@secondTodo').should('have.class', 'completed')
  })
})

NÃO devemos repetir os testes para as mesmas ações da interface do usuário. NÃO devemos repetir as interações da interface do usuário, mesmo que sigamos as práticas recomendadas e testemos os recursos, e não a implementação, usando IDs de teste e uma boa biblioteca auxiliar, como a Cypress-Testing-Library – ela ainda está vinculando nossos testes à estrutura da página a qual pode mudar.

Aqui está a manipulação original do aplicativo usando a interface do usuário.

cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
cy.createTodo(TODO_ITEM_TWO).as('secondTodo')
cy.get('@firstTodo').find('.toggle').check()

E aqui está como podemos refazer isso para usar app action (application actions). Primeiro, podemos usar nossa função de utilitário addTodo para controlar a aplicação e ainda usar a caixa de seleção class = “toggle” para alternar o primeiro item como concluído.

// spec.js
import { addTodos } from './utils';

addTodos(TODO_ITEM_ONE, TODO_ITEM_TWO)
cy.get('.todo-list li').eq(0).find('.toggle').check()

Em seguida, podemos ver os métodos de modelo no todoModel.js para ver como podemos alternar diretamente um item de tarefa.

app.TodoModel.prototype.toggle = function (todoToToggle) {
  this.todos = this.todos.map(function (todo) {
    return todo !== todoToToggle ?
      todo :
      Utils.extend({}, todo, {completed: !todo.completed});
  });

  this.inform();
};

Podemos usar o método model.toggle para alternar o sinalizador concluído? O Cypress pode fazer tudo o que você pode fazer no DevTools. Então, novamente, podemos abrir o DevTools a partir do executor de teste, alternar para o contexto “Your App” e tentar. Observe como, após o término do teste, chamei model.toggle (model.todos [0]) e o primeiro item do aplicativo voltou a ficar incompleto.

Vamos escrever uma função utilitária para chamar a alternância da application action. Para nossos testes, provavelmente queremos alternar um item não por referência, mas por índice.

/**
 * Toggle given todo item. Returns chain so you can attach more Cypress commands
 * @param {number} k index of the todo item to toggle, 0 - first item
 * @example
 import { addTodos, toggle } from './utils'
 it('completes an item', () => {
   addTodos('first')
   toggle(0)
 })
 */
export const toggle = (k = 0) =>
  cy.window().its('model')
  .then(model => {
    expect(k, 'check item index').to.be.lessThan(model.todos.length)
    model.toggle(model.todos[k])
  })

Como alternativa, poderíamos ter alterado a função de alternância do modelo do aplicativo para usar um índice como argumento. Veja como o código de teste agora é um “cliente” do código da aplicação e pode influenciar a arquitetura e o design da aplicação?

Nosso teste alterado cria os itens e alterna o primeiro e é executado rapidamente.

context('Persistence', function() {
  // mimicking TodoMVC tests
  // by writing out this function
  function testState() {
    cy.get('.todo-list li').eq(0)
      .should('contain', TODO_ITEM_ONE).and('have.class', 'completed')
    cy.get('.todo-list li').eq(1)
      .should('contain', TODO_ITEM_TWO).and('not.have.class', 'completed')
  }

  it('should persist its data', function() {
    addTodos(TODO_ITEM_ONE, TODO_ITEM_TWO)
    .toggle(0)
    .then(testState)

    .reload()
    .then(testState)
  })
})

Agora eu posso passar por outros testes e substituir todos os cy.get(‘.todo-list li’).eq(k).find(‘. toggle’).check() por toggle(k). Mais rápido e comprovado que funciona.

Da mesma forma, podemos atualizar os testes ponta-a-ponta de roteamento para NÃO passar pelos elementos da interface do usuário ao configurar a página, em vez de usar app action. Ao mesmo tempo, deixamos de clicar nos links reais que estamos testando – o teste está afirmando que o link da interface do usuário com o texto “Active” funciona!

context('Routing', function() {
  beforeEach(addDefaultTodos) // app action

  it('should allow me to display active items', function() {
    toggle(1) // app action
    // the UI feature we are actually testing - the "Active" link
    cy.get('.filters').contains('Active').click()
    cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE)
    cy.get('@todos').eq(1).should('contain', TODO_ITEM_THREE)
  })
  // more tests
})

Observe que sempre que você escrever uma função de teste utilitária um pouco mais longa, como alternar, é um bom indicador de que talvez a interface interna do aplicativo precise ser alterada em vez de escrever mais código de teste!

// hmm, maybe we need to add a `model.toggleIndex()` method?
export const toggle = (k = 0) =>
  cy.window().its('model')
    .then(model => {
      expect(k, 'check item index').to.be.lessThan(model.todos.length)
      model.toggle(model.todos[k])
    })

Se adicionarmos um método model.toggleIndex à aplicação, a mesma se tornará mais testável e talvez ainda mais fácil de desenvolver no futuro. O código de teste também será simplificado.

DRY test code

Cada bloco de testes é realmente apenas um clausure. Podemos usar isso para nossa vantagem com app actions. Os seletores do elemento passados ​​para um bloco de teste serão localizados neste bloco. Isso naturalmente mantém os seletores locais para cada clausure. Todos os blocos de teste a seguir podem usar app actions e não precisam saber sobre o seletor. No exemplo abaixo, observe os seletores NEW_TODO e TOGGLE_ALL.

describe('TodoMVC', function() {
  // entrada do item de teste
  context('New Todo', function() {
  // O seletor para inserir o novo item de tarefa é privado para esses testes
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function() {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      // mais comandos
    })
    // mais testes que usam o seletor NEW_TODO
  })

  // teste de alternância de todos os itens
  context('Mark all as completed', function() {
    // O seletor para alternar todos os itens é privado para esses testes
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function() {
      cy.get(TOGGLE_ALL).check()
      // mais comandos
    })
    // mais testes que usam o seletor TOGGLE_ALL
  })
})

Os testes acima mostram como cada seletor é privado para o bloco específico de testes. Por exemplo, o seletor const NEW_TODO = ‘.new-todo’ é privado para o bloco de testes “New Todo” e o seletor const TOGGLE_ALL = ‘.toggle-all’ é privado para o bloco de testes “Mark all as completed” . Outros testes não precisam conhecer os seletores para os elementos da página para adicionar itens ou marcar todos os concluídos – os testes podem usar app action.

Mas, em algumas situações, você pode querer compartilhar um seletor. Por exemplo, muitos testes de vários blocos podem precisar pegar todos os itens Todo na página e não há como fugir disso. Ainda podemos manter o seletor nos testes sem criar page objects como uma variável local ALL_ITEMS.

describe('TodoMVC', function() {
  // common selector used across many tests
  const ALL_ITEMS = '.todo-list li'

  context('New Todo', function() {
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function() {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      cy.get(ALL_ITEMS)
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function() {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function() {
      cy.get(TOGGLE_ALL).check()
      cy.get(ALL_ITEMS)
        .eq(0)
        .should('have.class', 'completed')
    })
    // mais testes
  })
})

No exemplo acima, estamos usando o seletor const ALL_ITEMS = ‘.todo-list li’ em vários testes. Eu até prefiro criar uma função utilitária local allItems para retornar todos os itens da lista em vez de compartilhar uma constante do seletor.

describe('TodoMVC', function() {
  const ALL_ITEMS = '.todo-list li'

  /**
   * Returns all todo items
   */
  const allItems = () => cy.get(ALL_ITEMS)

  context('New Todo', function() {
    const NEW_TODO = '.new-todo'
    it('should allow me to add todo items', function() {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      allItems()
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function() {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function() {
      cy.get(TOGGLE_ALL).check()
      allItems()
        .eq(0)
        .should('have.class', 'completed')
    })
    // mais testes
  })
})

À medida que o número de testes aumenta, podemos naturalmente dividir nosso único arquivo de espc em vários arquivos de espc. Isso permitiria ao nosso servidor de integração contínua executar todos os testes em paralelo. Nesse caso, podemos querer mover a função utilitária allItems e o seletor ALL_ITEMS para um arquivo utilitário comum e importar allItems de todas as specs necessárias.

// cypress/integration/utils.js
const ALL_ITEMS = '.todo-list li'

/**
 * Retornar todo items
 * @example
    import {allItems} from './utils'
    allItems().should('not.exist')
 */
export const allItems = () => cy.get(ALL_ITEMS)

// cypress/integration/spec.js
import {
  allItems
} from './utils'

describe('TodoMVC', function() {
  context('New Todo', function() {
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function() {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      allItems()
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function() {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function() {
      cy.get(TOGGLE_ALL).check()
        allItems()
          .eq(0)
          .should('have.class', 'completed')
    })
    // mais testes
  })
})


O objetivo é tornar os testes fáceis de ler, simples de entender e seguros de mudar quando necessário.

Foco no erro

Um belo benefício do uso de app actions para conduzir testes são o foco nos erros. Por exemplo, a interface de usuário da aplicação possui uma caixa de seleção para alternar todos os itens como concluídos. O código fica assim.

if (todos.length) {
  main = (
    <section className='main'>
      <input className='toggle-all'
        type='checkbox'
        onChange={this.toggleAll}
        checked={activeTodoCount === 0}
    	/>
      <ul className='todo-list'>{todoItems}</ul>
    </section>
  )
}

Se eu remover o elemento <input className = ‘toggle-all’ … />, apenas os testes dentro do bloco “Mark all as completed quebram.

Nenhum outro teste está passando por esse elemento da interface de usuário, portanto, nenhum outro teste quebra.

Da mesma forma, cada item Todo renderiza uma caixa de seleção para marcar seu próprio item como concluído. O código fica assim.

<input
  className="toggle"
  type = "checkbox"
  checked = {this.props.todo.completed}
  onChange = {this.props.onToggle}
/>

Se eu comentar a linha onChange = {this.props.onToggle} assim.

< input
  className = 'toggle'
  type = 'checkbox'
  checked = {this.props.todo.completed}
  // onChange={this.props.onToggle}
/>

Somente os testes para concluir os itens individuais quebram.

Estou realmente feliz por ter um recurso de página afetando apenas um único conjunto de testes. Esta é uma mudança bem-vinda do típico “alguma coisa pequena da interface do usuário mudou – metade dos testes de ponta-a-ponta estao vermelhos agora”.

Se usarmos TypeScript para escrever nosso aplicativo, nossos testes poderão até usar a definição do modelo de  interface da aplicação para chamar corretamente os métodos existentes. Isso aceleraria a refatoração, porque as definições de tipo permitiriam refatorar imediatamente todos os locais onde o código de teste está chamando no código do aplicação.

Limitações do App action

Chamando muitas ações muito rapidamente

Ao usar App action para executar várias operações, seus testes podem ser executados antes do aplicativo. Por exemplo, se a aplicação salvar todos os todos no servidor antes de armazená-los localmente, você não poderá marcá-los imediatamente como concluídos.

// model
app.TodoModel.prototype.addTodo = function(...todos) {
  // make XHR to the server to save todos
  ajax({
    method: 'POST',
    url: '/todos',
    data: todos
  }).then(() =>
    // then update local state
    this.saveTodos(todos)
  ).then(() =>
    // this triggers DOM render
    this.inform()
  )
}
// spec.js
it('completes all items', () => {
  addDefaultTodos()
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

O teste acima conclui todos os itens, e provavelmente às vezes passará, e as vezes falhará. E é tudo porque o teste é executado mais rapidamente do que o aplicativo pode lidar com ações.

Por exemplo, enquanto a aplicação ainda está adicionando novos todos dentro do método addTodo, o teste já está enviando uma ação de alternância que tentará concluir o item todo com o índice 1. Talvez a aplicação tenha tido tempo suficiente para enviar a lista original de todos para o servidor e definí-los no estado local – nesse caso, o teste íra passar. Mas, na maioria das vezes, a aplicação ainda está aguardando a resposta do servidor – nesse caso, a lista local de itens ainda está vazia e tentar alternar o item com o índice 1 acionará um erro.

Ao usar app action para guiar a aplicação, nos afastamos da maneira como um usuário usaria nossa aplicação. Os usuários não poderiam alternar um item antes que um item seja mostrado ao usuário na página. Portanto, nossos testes precisam esperar que os itens apareçam na interface de usuário antes de executar a toggle(1). Novamente, uma simples função reutilizável deve ser suficiente.

it('completes all items', () => {
  addDefaultTodos()
  allItems().should('have.length', 3)
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

Eu recomendo o padrão mostrado acima – execute uma ação na aplicação, aguarde a atualização da interface do usuário para o estado desejado escrevendo uma asserção, execute outra ação na aplicação e aguarde novamente a atualização da interface do usuário. Isso é executado o mais rápido possível, pois o Cypress pode observar diretamente o DOM e continuar com a próxima ação assim que a afirmação passar.

Você não está restrito a observar o DOM – você  também pode espionar facilmente as chamadas de rede. Por exemplo, podemos espionar a chamada  XHR POST / todos do aplicativo para o servidor e aguardar a chamada de rede antes de executar a alternância de toggle (1).

it('completes all items', () => {
  cy.server()
  cy.route('POST', '/todos').as('save')
  addDefaultTodos()
  cy.wait('@save') // waits for XHR POST /todos before test continues
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

A interface de usuário do Cypress mostra informações sobre cada método que estamos espionando no log de comandos.

Para resumir: as app action podem ser chamado a partir do teste mais rapidamente do que o aplicativo pode processá-las. Nesse caso, você pode interpretar os testes como flaky devido à recorrência entre o teste e o aplicaçao. Felizmente, você pode sincronizar o teste e a aplicação de várias maneiras. O teste pode:

  1. Aguardar que DOM seja atualizado conforme o esperado.
  2. Observar o tráfego da rede e aguardar uma chamada XHR esperada.
  3. Espionar um método no aplicativo e continuar quando for chamado.

Ações restritas 

Às vezes, o código da aplicacao não consegue realizar a ação desejada. Por exemplo no Cypress Best Practices fala, Brian Mann argumentou que:

  • Ao testar uma página de login, os testes ponta a ponta devem usar a IU, assim como o usuário faz
  • Ao testar qualquer outro fluxo de usuário que requer um login, o teste deve executar o login diretamente (por exemplo, usando o comando cy.request()) e não passar pela IU repetidamente.

Na implementação acima, o código do aplicativo não pode fazer o login usando o mesmo método que o cy.request. Portanto, os testes de ponta a ponta devem chamar cy.request () e não invocar uma app action. Isso ainda evita o uso do padrão page object – um comando customizado ou uma função simples é suficiente para alcançá-lo.

Pensamentos finais

Mudar de page objects que sempre passam pela interface do usuário da página para app action que controlam o aplicativo por meio de sua API de modelo interno traz muitos benefícios.

  • Os testes se tornam muito mais rápidos. Até os testes simples de TodoMVC, executados localmente no navegador Electron do Cypress, passaram de 34 segundos para 17 segundos depois de passar da interface do usuário para o uso de app action – umaumento na velocidade de 50%.
  • Os testes agora influenciam e se beneficiam da refatoração do código da aplicação. Quanto mais sensata e melhor documentada a interface interna da aplicação se tornar, mais fácil será escrever testes de ponta-a-ponta para eles.
  • Você evita escrever uma camada de código separada de baixo acoplamento sobre interfaces de usuário efêmeras e instáveis. Em vez disso, os testes usam e estão vinculados à modelos de interface da aplicação interna mais duradouros.

De fato, as funções utilitárias que eu tinha que escrever apenas mapeando os testes de sintaxe para as app action, e a maioria são apenas de sintaxe sem estado, como syntax sugar.

export const addTodos = (...todos) => {
  cy.window().its('model').invoke('addTodo', ...todos)
}

Não há estado paralelo (dentro dos page objects), nem lógica de teste condicional – apenas invocacao direta do código da aplicação, como você pode fazer no console do DevTools.


Mais informações

Você pode encontrar o código fonte para esta postagem sobre App Action. Também, pode ver os mesmos testes implementados em estilos diferentes, incluindo Page Object e App Action no repositório bahmutov/test-todomvc-using-app-actions.

Você pode usar a mesma ideia de App Action para controlar qualquer aplicação de testes. Em outros posts, você aprenderá:

Os exemplos acima e as postagens do blog me ajudaram a ver as deficiências do Page Object e os benefícios das App Action em testes de ponta a ponta.

Glossario:

  • Linters: são ferramentas, originalmente usadas na linguagem C, que analisam a base de código em busca de erros.
  • Teste ponta a ponta: é um forma de realizar testes que visa verificar o sistema de uma forma mais completa, simulando o ambiente real.
  • Bundler: é uma ferramenta que permite que a função require funcione no navegador.

1 thought on “Abandone Page Objects e comece a aplicar App Actions”

  1. André Roggeri Campos

    A primeira vez que eu li esse artigo fiquei “chateado” de saber que o que eu uso no dia a dia estava sendo considerado como má prática.
    Hoje relendo a sua versão traduzida, me veio algumas reflexões, e acho que no fim estamos comparando soluções diferentes para problemas diferentes.

    Primeiras considerações:

    “PageObjects são difíceis de manter e tomam tempo do desenvolvimento real da aplicação. Nunca vi Page Objects documentados o suficiente para realmente ajudar a escrever testes.”

    Talvez a aplicação dos POs no seu projeto não foram muito boas, inclusive os exemplos do artigo são ruins. SignInPage não deveria ter esse monte de método, e sim apenas um “authenticate(email, password)” por exemplo.

    “Page Objects introduzem estado adicional nos testes, que é separado do estado interno da aplicação. Isso dificulta a compreensão dos testes e falhas.”

    POs com estado estão errados. Eles são uma camada de abstração da interface, mas não devem manter nada em sua instancia além do driver.

    “Page Objects tentam encaixar vários casos em uma interface uniforme, voltando à lógica condicional – um enorme anti-pattern em nossa opinião”

    Não deveria ter lógica no PO, e testes condicionais não estão relacionados com POs (Tanto que são possíveis com app actions tb)

    “Page Objects tornam os testes lentos porque forçam os testes a sempre passarem pela interface do usuário da aplicação.”

    É aqui que eu vejo que estamos comparando coisas diferentes. POs foram feitos exclusivamentes nos seus testes de interface. App Actions propõem um novo meio de testar, o que é ótimo e eu achei muito interessate. O autor original critica os Page Objects, mas acredioq eu na vdd o que ele quis dizer é: Não faça todos os seus testes pela UI, pegue atalhos sempre que possível para deixar os seus testes rápidos e focados. E nisso eu concordo com ele !

    No mais, parabéns pelo trampo de traduzir, ficou mto bom ! 😉

Leave a Reply

Your email address will not be published. Required fields are marked *