Testes End to End com Drupal e Cypress
Esse post é uma tradução do artigo “End to End Testing With Drupal and Cypress” do blog Sevaa Group! Espero que curtam o conteúdo, boa leitura! 😀
Recentemente, começamos a usar o Cypress para lidar com testes End to End nos nossos sites em Drupal. Até então, a experiência tem sido ótima! O Cypress facilita a adição rápida de novos testes ao seu site à medida que você itera seu código. O processo, no entanto, não foi completamente sem obstáculos. Enquanto busco maneiras de reduzir o desenvolvimento de testes e o tempo de execução, descobri alguns conceitos úteis especificamente para sites em Drupal. Acompanhe enquanto percorro alguns desses conceitos ou faça clone ou download do repositório de exemplo.
Configurações
Para servir nosso site de exemplo, usarei o Aquia Dev Desktop com a distribuição do Drupal 8 e Standard profile.
O único módulo que vamos adicionar a mais será o JSON API. O Drupal 8 vem com RESTful Web Services, que podem atender a muitos dos mesmos propósitos. No entanto, descobri que a JSON API facilita algumas coisas, como a consulta de nodes por field.
Nota: este artigo será demonstrado apenas no Drupal 8. Se o seu site estiver no Drupal 7, descobri que o processo é muito semelhante usando o módulo RESTful Web Services.
Você tem algumas opções para instalar o Cypress, mas minha opção preferida é por meio de um package.json NPM (ou Yarn). O primeiro passo para esta rota é criar o nosso arquivo package.json na raiz do projeto. Quando o arquivo estiver no lugar, instale-o executando npm i na raiz do projeto.
{ "name": "cypress_testing", "scripts": { "cy:dd": "cypress open --config baseUrl=http://drupal-cypress.dd:8083" }, "devDependencies": { "cypress": "^3.1.0" } }
Este exemplo é bem simples, mas fornece um script para executar o Cypress com npm run cy:dd. O script também passará o baseUrl do nosso site (no meu caso, http://drupal-cypress.dd:8083) para o Cypress como uma flag permitindo-nos usar caminhos relativos (por exemplo, “/login”) em nossos testes. Em sua primeira inicialização, o Cypress criará um diretório (skeleton) em cypress/ com algumas specs de exemplo em cypress/integration/examples/. Sinta-se à vontade para ver alguns deles clicando no painel do Cypress. Você pode manter esse diretório de exemplo para referência e inspiração ou ignorá-lo.
Primeiro teste
Nota: O código neste repositório usará alguns conceitos assíncronos como encadeamento .then(…) fora de comandos Cypress. Os comandos do Cypress se comportam como promises e a familiaridade com elas será assumida ao percorrer o código.
Vamos escrever um teste rápido apenas para verificar se tudo está funcionando corretamente. Primeiro, precisaremos criar um arquivo spec (cypress/integration/homepage.spec.js) para o teste.
describe('Homepage', function() { it('visits homepage', function() { cy.visit('/'); cy.get('.site-branding__name') .contains('Cypress Testing'); }); });
O teste faz duas coisas:
- Visita o endereço raiz do nosso site (configurado pelo nosso script)
- Verifica se a página tem um elemento com “Cypress Testing” (o nome do nosso site).
Criação de conta de usuário
Existem algumas maneiras de adicionar contas de usuários. Dependendo do seu ambiente, algumas opções podem ser mais viáveis do que outras. Passaremos por algumas dessas opções. Para fazer as coisas que precisamos fazer, como criar entidades do Drupal, precisaremos de acesso a uma conta de administrador. Poderíamos criar manualmente a conta em nosso banco de dados e passar as credenciais da conta para o Cypress por meio de uma variável de ambiente. Porém, essa abordagem adicionaria uma dependência do ambiente e reduziria a natureza autônoma de nossos testes. Em vez disso, prefiro que o Cypress crie essa conta sempre que executar os testes. Isso diminui a chance de nossos testes falharem logo de cara devido à falta de acesso de administrador. O comando cy.exec() nos permite acesso aos comandos do sistema e, especificamente, no nosso caso, o Drush.
Primeiro, precisamos decidir as credenciais do nosso usuário de teste. Em cypress.json, podemos adicionar um objeto env com as chaves de acesso que serão passadas para nossos testes como variáveis de ambiente. As variáveis poderão ser acessadas chamando o Cypress.env(‘ourCustomVariableName’). Adicionaremos o nome de usuário e senha para o usuário administrador que queremos criar.
{ "env": { "cyAdminUser": "admin", "cyAdminPassword": "password" } }
Agora que nossas credenciais estão disponíveis, podemos usá-las para criar o usuário em cypress/support/index.js. Precisaremos dele para executar qualquer teste.
before(function(){ createAdminUser(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')); // ... }); const createAdminUser = function(user, pass){ cy.exec( `drush ucrt ${user} --password="${pass}"`, { failOnNonZeroExit: false } ); cy.exec( `drush user-add-role administrator ${user}`, { failOnNonZeroExit: false } ); cy.exec(`drush uinf ${user}`); }
Aqui, há alguns pontos para destacar. Primeiro, colocamos a criação do usuário em uma função própria que é executada no before() hook callback. Qualquer código nesse callback será executado uma vez antes do Cypress executar qualquer spec única ou conjunto de specs. Em segundo lugar, usamos o cy.exec() com o Drush para tentar criar uma conta de usuário. E o mais importante: nós passamos em um segundo parâmetro para o cy. um objeto com a propriedade failOnNonZeroExit definida como false. Isso permitirá que nosso código continue sendo executado caso a conta do usuário já exista. Usamos o mesmo método para adicionar a função de administrador ao usuário.
Login
Com o objetivo de testar qualquer ação restrita a usuários autenticados, primeiro precisamos fazer o login. A maneira mais óbvia de fazer isso é da mesma maneira que um usuário faria login, através da interface do usuário. Na verdade, devemos testar(cypress/integration/login.spec.js) para garantir que o login através do nosso interface do usuário seja possível.
describe('Login', function() { it('logs in via ui', function(){ cy.visit('/user/login'); cy.get('#edit-name').type(Cypress.env('cyAdminUser')); cy.get('#edit-pass').type(Cypress.env('cyAdminPassword')); cy.get('#edit-submit').click(); }); // ... });
Após cada teste, o Cypress deixa o seu navegador no estado em que estava quando o seu último teste terminou. Acho isso útil porque me deixa em ótima posição para determinar as próximas etapas (por exemplo, determinar DOM querySelector apropriado de um campo ou botão). Para este caso em particular, Cypress irá retornar o navegador para nós com o nosso usuário administrador logado.
Para manter os testes independentes um do outro, o Cypress limpa os cookies do navegador antes de cada teste ser executado. Isso ajuda a evitar efeitos colaterais entre os testes, mas também significa que você precisará efetuar login toda vez que um teste for executado que exige autenticação.
Como é provável que precisemos fazer o login para várias specs diferentes, devemos colocar o código de login em algum lugar que seja acessível a todas as specs. O cypress/support/commands.js é o lugar perfeito para isso. Neste arquivo, podemos declarar nossos próprios comandos personalizados através da função Cypress.Commands.add(). Uma vez declarado, um comando poderá ser chamado em qualquer lugar em seus testes usando cy.OurCustomCommandName().
Agora que temos um lugar para nosso código de login, precisamos escrevê-lo. Nós poderíamos apenas reutilizar o código de login via interface do usuário, mas se tivermos que executar esse mesmo código antes de cada teste, não faz sentido em ter o teste, para começar. Mais importante, o login através da interface do usuário é lento. Se tivermos que fazer o login antes de cada teste, ao longo da execução de vários testes, muito tempo será desperdiçado ao fazer login. O Drupal (pelo menos no caso do nosso site de teste) efetua login simplesmente enviando dados do formulário para o URL de login. Podemos confirmar isso e roubar uma grande parte do nosso código de login, observando as network requests enquanto o teste de login via interface do usuário é executado.
// ... Cypress.Commands.add("login", (user, password) => { return cy.request({ method: 'POST', url: '/user/login', form: true, body: { name: user, pass: password, form_id: 'user_login_form' } }); }); Cypress.Commands.add('logout', () => { return cy.request('/user/logout'); }); // ...
Agora que podemos fazer login com facilidade antes dos testes, vamos testá-lo usando a funcionalidade de logout. Este código acabou por ser um pouco mais complicado do que o pretendido. Porém, fornece uma oportunidade para apontar alguns dos recursos do Cypress.
describe('Login', function() { // ... it('logs out via ui', function(){ cy.login(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')); cy.server(); cy.route('POST', '/quickedit/*').as('quickEdit'); cy.visit('/'); cy.wait('@quickEdit'); cy.get('#block-bartik-account-menu a') .contains('Log out') .click({force: true}); // This is a workaround due to the admin bar getting in the way. Not a great approach. }); });
A implementação planejada deste teste foi:
- Use nosso comando customizado de login para logar
- Query para o botão “Efetuar logout”
- Clicar
O primeiro obstáculo com essa abordagem foi que o Cypress não pôde ver o botão de “Log out” devido à posição da barra de administração. O Cypress falhará em determinados comandos se detectar que o elemento consultado não é visível para o usuário. Isso normalmente é uma coisa boa. Depois de lutar por muito tempo para garantir que o botão estava sendo mostrado, finalmente desisti e forcei o Cypress a clicar no botão passando a opção “force”. Isso elimina muito a utilidade que esse teste pretendia fornecer, então eu não recomendaria fazer o mesmo para um site que você está realmente testando. Por enquanto, vamos fingir que funcionou sem a opção force.
O segundo problema era que o Cypress estava efetuando logout antes de o Drupal concluir o login. Isso estava causando um request AJAX para ‘/quickedit/attachments?_wrapper_format=drupal_ajax’ para retornar uma resposta 403. Nesse caso, eu precisava garantir que o Cypress não tentasse fazer logout até que a solicitação fosse retornada. O comando cy.wait() permite que você espere por um período de tempo específico ou por um recurso solicitado para resolver. Nós primeiro chamamos o comando cy.server() para iniciar um server que pode observar nossa request especificada. Em seguida, descrevemos a solicitação por seu método e URL e, em seguida, anexamos o alias ‘quickEdit’ ao encadeamento do comando .as(). As informações do request estavam aparecendo no log do Cypress, mas você também pode abrir a guia network do dev tools para visualizá-las.
Agora nós chamamos cy.wait(‘@quickEdit’) e encadeiamos o comando .click() fora disso. Agora o Cypress aguardará que a request quickEdit seja resolvida antes de tentar clicar no botão “Log out”.
Seeding dados através da JSON API
Agora, devemos analisar como podemos usar a JSON API para propagar os dados que gostaríamos de testar. É importante entender como a API autentica suas requests. Por padrão, para qualquer request não segura/não somente leitura (POST, DELETE, PATCH), tanto o JSON quanto o módulo REST padrão exigem que um X-CSRF-Token request header esteja presente. Você pode solicitar um desses tokens para seu site em ${your-site}/session/token enquanto estiver logado. Agora podemos usar esse token para criar e excluir dados postando nos endpoints expostos pelo módulo JSON API.
const testArticleFields = { title: { value: 'Cypress Test Article' }, body: { value: 'Body here' } }; before(function(){ cy.getRestToken(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')).then(token => { return cy.reseedArticle(token, testArticleFields) .as('testArticle'); }); cy.logout(); }); describe('Article', function() { it('displays published articles', function(){ cy.visit(`/node/${this.testArticle.data.attributes.nid}`); cy.get('h1').contains(testArticleFields.title.value); cy.get('.field--name-body').contains(testArticleFields.body.value); }); });
Cypress.Commands.add("createNode", (token, nodeType, fields) => { return cy.request({ method: 'POST', url: `/jsonapi/node/${nodeType}`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, body: { data: { type: `node--${nodeType}`, attributes: fields } }, }).its('body'); }); Cypress.Commands.add("deleteNode", (token, nodeType, uuid) => { return cy.request({ method: 'DELETE', url: `/jsonapi/node/${nodeType}/${uuid}`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, }).its('body'); }); // ... Cypress.Commands.add("getNodesWithTitle", (token, nodeType, title) => { return cy.request({ method: 'GET', url: `/jsonapi/node/${nodeType}?filter[article-title][path]=title&filter[article-title][value]=${title}&filter[article-title][operator]==`, headers: { 'Accept': 'application/vnd.api+json', 'Content-Type': 'application/vnd.api+json', 'X-CSRF-Token': token }, }).then(res => { return JSON.parse(res.body).data; }); }); Cypress.Commands.add("getRestToken", (user, password) => { cy.login(user, password); return cy.request({ method: 'GET', url: '/session/token', }).its('body'); }); // ... Cypress.Commands.add('reseedArticle', (token, fields) => { cy.getNodesWithTitle(token, 'article', fields.title.value) .then(nodes => { nodes.map(function(node){ cy.deleteNode(token, 'article', node.id); }); }); return cy.createNode(token, 'article', fields); }); // ...
Algumas coisas a notar sobre este código: No before hook, primeiro pegamos o nosso token de autenticação, e então nós começamos um objeto com os nossos campos Article para chamar um comando customizado cy.reseedArticle() . Esse comando primeiro procura e exclui todos os nodes dos Article que possuem o mesmo título do nosso Article de teste, depois cria um novo Article com os campos desejados. cy.seedArticle() retornará nosso Article de teste como um objeto que será salvo no alias testArticle por encadeamento .as(‘testArticle’) para que possamos referenciar suas propriedades nos testes.
É importante notar também que Cypress expõe um after hook. É tentador excluir nossos nodes de teste no after hook, pois, nesse momento, teríamos acesso ao ID do node de teste e poderíamos excluir o conteúdo do teste sem precisar query pelo título. No entanto, essa abordagem pode ser problemática no caso de o test runner encerrar ou atualizar antes de executar o bloco posterior. Nesse caso, o conteúdo do teste nunca seria limpo, pois você não teria acesso ao id do node em execuções de teste futuras. Depois que nosso Article de teste for propagado, o teste “exibe articles publicados” visitará a página do node e confirmará que os campos nos quais nós passamos serão processados corretamente.
Reseeding de dados com dumps de banco de dados
A última coisa que gostaria de abordar é redefinir seu banco de dados quando a JSON API não for atingível. Para conteúdo que requer relacionamentos complicados, o uso das JSON APIs ou REST APIs pode se tornar muito tedioso. Eu pessoalmente encontrei este problema para sites que usam o Módulo de Grupos Orgânicos. Nessas situações, pode não valer a pena configurar esses relacionamentos complicados por meio da API. Em vez disso, você pode optar por utilizá-las uma vez por meio de outro método menos complexo, mas potencialmente mais lento para executar (como o ui), criar um backup e restaurar esse backup toda vez que precisar redefinir o banco de dados.
Como os dados dos comentários dependem de relacionamentos, vamos usá-los como exemplo.
const testArticleFields = { title: { value: 'Cypress Test Article' }, body: { value: 'Body here' } }; const testCommentFields = { subject: 'Cypress test comment subject', body: '< p>Cypress test comment body</ p>' }; const seedCommentThroughUi = function(articleNid, fields) { cy.visit(`/node/${articleNid}`); cy.get('.comment-form input[name="subject[0][value]"]').type(fields.subject); cy.window().then(win => { win.CKEDITOR.instances['edit-comment-body-0-value'].insertHtml(fields.body); }); cy.get('.comment-form #edit-submit').click(); } before(function(){ cy.getRestToken(Cypress.env('cyAdminUser'), Cypress.env('cyAdminPassword')).then(token => { return cy.reseedArticle(token, testArticleFields) .as('commentTestArticle'); }).then(function(){ seedCommentThroughUi(this.commentTestArticle.data.attributes.nid, testCommentFields); }); cy.dumpDb('comment-seeded'); }); describe('Comment', function() { it('displays published comments', function(){ cy.restoreDb('comment-seeded'); cy.visit(`/node/${this.commentTestArticle.data.attributes.nid}`); }); });
Primeiro, propomos novamente um Article de teste da mesma forma que fizemos na spec do Article. Para anexar um comentário ao Article, nós preenchemos os elementos do formulário de comentários quase da mesma maneira que um usuário faria. Uma coisa a notar aqui é que o CKEditor usa um iframe para exibir o campo do corpo do comentário. Existem maneiras documentadas de manipular elementos iframe no Cypress, no entanto, o CKEditor expõe uma API Javascript para manipular suas instâncias através de uma variável global. Podemos acessar isso chamando o comando cy.window().
Depois que o Article e o comentário relacionado são criados, nós fazemos dump de uma cópia do banco de dados em cypress/backups/ usando o cy.exec() e Drush. É útil criar alguns comandos personalizados para isso, para que possamos criar ou replicar um backup chamando cy.dumpDb(‘nosso-backup-name’) ou cy.restoreDb(‘nosso-backup-nome’), respectivamente. Isso não é muito útil em seu estado atual, mas pode ser muito útil para uma estrutura de dados mais complexa.
Não há uma “bala de prata” para escrever testes E2E no Drupal/Cypress. Seu ambiente exigirá uma abordagem específica. Isto é o que tem funcionado para nós até agora, e já começou a valer a pena em sites que o implementaram. Descobri que esses comandos personalizados podem ser facilmente transferidos para outros projetos do Drupal, bem como pontos de partida úteis para outros CMSs.
O artigo original você encontra aqui.