Desenvolvimento de testes visuais baseados em Gemini e Storybook

Olá Habr! Neste artigo, quero compartilhar a experiência de desenvolvimento de testes visuais em nossa equipe.

Aconteceu que não pensamos imediatamente no teste de layout. Bem, algum quadro será movido por alguns pixels, conserte-o. No final, existem testadores - a mosca não passa por eles. Mas o fator humano ainda não pode ser enganado - detectar pequenas alterações na interface do usuário nem sempre é fisicamente possível, mesmo para um testador. A questão surgiu quando se iniciou uma otimização séria do layout e da transição para o BEM. Aqui, certamente não teria sido sem perdas, e precisávamos desesperadamente de uma maneira automatizada de detectar situações em que, como resultado de edições, algo na interface do usuário começa a mudar não como pretendido ou não para onde foi pretendido.

Qualquer desenvolvedor conhece o teste de código de unidade. Os testes de unidade dão confiança de que as alterações no código não quebraram nada. Bem, pelo menos eles não quebraram a parte para a qual existem testes. O mesmo princípio pode ser aplicado à interface do usuário. Assim como os testes de unidade testam as classes, os testes visuais testam os componentes visuais que compõem a interface do usuário de um aplicativo.

Para componentes visuais, você pode escrever testes de unidade "clássicos", que, por exemplo, iniciam a renderização de componentes com diferentes valores de parâmetros de entrada e verificam o estado esperado da árvore DOM usando instruções assert, comparando elementos individuais ou uma captura instantânea da árvore DOM do componente com a referência em geral Os testes visuais também são baseados em snapshots, mas já em snapshots da exibição visual do componente (screenshots). A essência do teste visual é comparar a foto tirada durante o teste com a de referência e, se forem encontradas diferenças, aceite a nova imagem como referência ou corrija o erro que causou essas diferenças.

Obviamente, “rastrear” componentes visuais individuais não é muito eficaz. Os componentes não vivem no vácuo e sua exibição pode depender dos componentes de nível superior ou dos vizinhos. Não importa como testamos componentes individuais, a imagem como um todo pode ter defeitos. Por outro lado, se você tirar fotos de toda a janela do aplicativo, muitas delas conterão os mesmos componentes, o que significa que, se você alterar um componente, seremos obrigados a atualizar todas as fotos nas quais esse componente está presente.

A verdade, como sempre, está em algum lugar no meio - você pode desenhar a página inteira do aplicativo, mas tire uma foto de apenas uma área sob a qual o teste é criado. No caso específico, essa área pode coincidir com a área de um componente específico, mas isso não será um componente no vácuo, mas em um ambiente muito real. E isso já será semelhante a um teste visual unitário, embora dificilmente se possa dizer sobre modularidade se a “unidade” souber algo sobre o meio ambiente. Bem, tudo bem, não é tão importante se a categoria de testes inclui testes visuais - modulares ou de integração. Como diz o ditado, "você verifica ou vai?"

Seleção de ferramenta


Para acelerar a execução dos testes, a renderização da página pode ser feita em algum navegador sem cabeça que faz todo o trabalho na memória sem ser exibido na tela e garante o desempenho máximo. Mas, no nosso caso, era fundamental garantir que o aplicativo funcionasse no Internet Explorer (IE), que não possui um modo sem cabeça, e precisávamos de uma ferramenta para gerenciar programaticamente os navegadores. Felizmente, tudo já foi inventado diante de nós e existe esse instrumento - ele se chama Selênio . Como parte do projeto Selenium, os drivers estão sendo desenvolvidos para gerenciar vários navegadores, incluindo um driver para o IE. O servidor Selenium pode gerenciar navegadores não apenas localmente, mas também remotamente, formando um cluster de servidores selenium, a chamada grade de selênio.

O selênio é uma ferramenta poderosa, mas o limiar para sua entrada é bastante alto. Decidimos procurar ferramentas prontas para testes visuais baseados em Selenium e encontramos um maravilhoso produto da Yandex chamado Gemini . Gêmeos pode tirar fotos, incluindo fotos de uma determinada área da página, comparar fotos com as de referência, visualizando a diferença e levando em consideração momentos como anti-aliasing ou um cursor piscando. Além disso, o Gemini pode executar reprises de testes reprovados, paralelizar a execução de testes e muitos outros benefícios. Em geral, decidimos tentar.

Os testes de Gêmeos são fáceis de escrever. Primeiro, você precisa preparar a infraestrutura - instale o selenium autônomo e inicie o servidor selenium. Em seguida, configure o gemini, especificando o endereço do aplicativo em teste (rootUrl), o endereço do servidor selenium (gridUrl), a composição e a configuração dos navegadores, bem como os plugins necessários para gerar relatórios, otimizando a compactação da imagem. Exemplo de configuração:

//.gemini.js module.exports = { rootUrl: 'http://my-app.ru', gridUrl: 'http://127.0.0.1:4444/wd/hub', browsers: { chrome: { windowSize: '1920x1080', screenshotsDir:'gemini/screens/1920x1080' desiredCapabilities: { browserName: 'chrome' } } }, system: { projectRoot: '', plugins: { 'html-reporter/gemini': { enabled: true, path: './report' }, 'gemini-optipng': true }, exclude: [ '**/report/*' ], diffColor: '#EC041E' } }; 

Os próprios testes são uma coleção de suítes, em cada uma das quais uma ou mais fotos (estados) são tiradas. Antes de tirar uma captura instantânea (método capture ()), você pode definir a área da página a ser capturada usando o método setCaptureElements () e também executar algumas ações preparatórias, se necessário, no contexto do navegador, usando os métodos do objeto de ações ou o código JavaScript arbitrário - para isso em ações possui um método executeJS ().

Um exemplo:

 gemini.suite('login-dialog', suite => { suite.setUrl('/') .setCaptureElements('.login__form') .capture('default'); .capture('focused', actions => actions.focus('.login__editor')); }); 

Dados de teste


Uma ferramenta de teste foi escolhida, mas ainda estava longe da solução final. Era necessário entender o que fazer com os dados exibidos nas imagens. Deixe-me lembrá-lo de que, nos testes, decidimos não desenhar componentes individuais, mas toda a página do aplicativo, para testar os componentes visuais não no vácuo, mas no ambiente real de outros componentes. Se você precisar transferir os dados de teste necessários para o ee props (estou falando de componentes de reação) para renderizar um componente individual, será necessário muito mais para renderizar a página inteira do aplicativo, e preparar o ambiente para esse teste pode ser uma dor de cabeça.

Obviamente, você pode deixar o aplicativo em si para receber dados, para que durante o teste ele execute solicitações ao back-end, que, por sua vez, receberia dados de algum tipo de banco de dados de referência, mas e o controle de versão? Você não pode colocar um banco de dados em um repositório git. Não, é claro que você pode, mas existem algumas decências.

Como alternativa, para executar testes, você pode substituir o servidor de back-end real por um falso, o que daria ao aplicativo Web não dados do banco de dados, mas dados estáticos armazenados, por exemplo, no formato json, já com as fontes. No entanto, a preparação desses dados também não é muito trivial. Decidimos seguir o caminho mais fácil - não para extrair os dados do servidor, mas simplesmente para restaurar o estado do aplicativo (no nosso caso, o estado do armazenamento redux ), que estava no aplicativo no momento em que a foto de referência foi tirada, antes de executar o teste.

Para serializar o estado atual do repositório redux, o método snapshot () foi adicionado ao objeto de janela:

 export const snapshotStore = (store: Object, fileName: string): string => { let state = store.getState(); const file = new Blob( [ JSON.stringify(state, null, 2) ], { type: 'application/json' } ); let a = document.createElement('a'); a.href = URL.createObjectURL(file); a.download = `${fileName}.testdata.json`; a.click(); return `State downloaded to ${a.download}`; }; const store = createStore(reducer); if (process.env.NODE_ENV !== 'production') { window.snapshot = fileName => snapshotStore(store, fileName); }; 

Usando este método, usando a linha de comando do console do navegador, você pode salvar o estado atual do armazenamento redux em um arquivo:

imagem

Como uma infraestrutura para testes visuais, o Storybook foi escolhido - uma ferramenta para o desenvolvimento interativo de bibliotecas de componentes visuais. A idéia principal era que, em vez dos vários estados dos componentes na árvore de histórias, corrija os vários estados de nosso aplicativo e use esses estados para fazer capturas de tela. No final, não há diferença fundamental entre componentes simples e complexos, exceto na preparação do ambiente.

Portanto, cada teste visual é uma história, antes da renderização do qual o estado do armazenamento redux salvo anteriormente no arquivo é restaurado. Isso é feito usando o componente Provider da biblioteca react-redux, para a propriedade de armazenamento da qual o estado desserializado restaurado do arquivo salvo anteriormente é passado:

 import preloadedState from './incoming-letter.testdata'; const store = createStore(rootReducer, preloadedState); storiesOf('regression/Cards', module) .add('IncomingLetter', () => { return ( <Provider store={store}> <MemoryRouter> <ContextContainer {...dummyProps}/> </MemoryRouter> </Provider> ); }); 

No exemplo acima, ContextContainer é um componente que inclui o "esqueleto" do aplicativo - a árvore de navegação, o cabeçalho e a área de conteúdo. Na área de conteúdo, vários componentes podem ser renderizados (lista, cartão, caixa de diálogo etc.) dependendo do estado atual do armazenamento redux. Para que o componente não atenda solicitações desnecessárias ao back-end para entrada, as propriedades de stub correspondentes são passadas para ele.

No contexto de um livro de histórias, ele se parece com isso:

imagem

Livro de histórias de Gemini +


Então, descobrimos os dados para os testes. A próxima tarefa é fazer amizade com Gemini e Storybook. À primeira vista, tudo é simples - na configuração do Gemini, especificamos o endereço do aplicativo em teste. No nosso caso, este é o endereço do servidor Storybook. Você só precisa aumentar o servidor do livro de histórias antes de iniciar os testes de gêmeos. Você pode fazer isso diretamente do código usando a assinatura de evento Gemini START_RUNNER e END_RUNNER:

 const port = 6006; const cofiguration = { rootUrl:`localhost:${port}`, gridUrl: seleniumGridHubUrl, browsers: { 'chrome': { screenshotsDir:'gemini/screens', desiredCapabilities: chromeCapabilities } } }; const Gemini = require('gemini'); const HttpServer = require('http-server'); const runner = new Gemini(cofiguration); const server = HttpServer.createServer({ root: './storybook-static'}); runner.on(runner.events.START_RUNNER, () => { console.log(`storybook server is listening on ${port}...`); server.listen(port); }); runner.on(runner.events.END_RUNNER, () => { server.close(); console.log('storybook server is closed'); }); runner .readTests(path) .done(tests => runner.test(tests)); 

Como servidor de testes, usamos o servidor http, que retorna o conteúdo da pasta com o livro de histórias montado estaticamente (para criar o livro de histórias estático, use o comando build-storybook ).

Até agora, tudo correu bem, mas os problemas não se mantiveram esperando. O fato é que o livro de histórias exibe a história dentro do quadro. Inicialmente, queríamos poder definir a região seletiva da imagem usando setCaptureElements (), mas isso só pode ser feito se você especificar o endereço do quadro como o endereço do conjunto, algo como isto:

 gemini.suite('VisualRegression', suite => suite.setUrl('http://localhost:6006/iframe.html?selectedKind=regression%2Fcards&selectedStory=IncomingLetter') .setCaptureElements('.some-component') .capture('IncomingLetter') ); 

Mas acontece que, para cada foto, devemos criar nossa própria suíte, porque O URL pode ser definido para o conjunto como um todo, mas não para um único instantâneo dentro do conjunto. Deve-se entender que cada suíte é executada em uma sessão separada do navegador. Isso, em princípio, está correto - os testes não devem depender um do outro, mas a abertura de uma sessão separada do navegador e o carregamento subsequente do Storybook levam muito tempo, muito mais do que apenas passar pelas histórias dentro da estrutura do Storybook já aberto. Portanto, com um grande número de suítes, o tempo de execução do teste é muito lento. Parte do problema pode ser resolvida paralelizando a execução dos testes, mas a paralelização consome muitos recursos (memória, processador). Portanto, tendo decidido economizar recursos e, ao mesmo tempo, não perder muito na duração da execução do teste, recusamos abrir o quadro em uma janela separada do navegador. Os testes são realizados em uma única sessão do navegador, mas antes de cada captura, a próxima história é carregada no quadro como se tivéssemos aberto o livro de histórias e clicássemos em nós individuais na árvore de histórias. Área da imagem - quadro inteiro:

 gemini.suite('VisualRegression', suite => suite.setUrl('/') .setCaptureElements('#storybook-preview-iframe') .capture('IncomingLetter', actions => openStory(actions, 'IncomingLetter')) .capture('ProjectDocument', actions => openStory(actions, 'ProjectDocumentAccess')) .capture('RelatedDocuments', actions => { openStory(actions, 'RelatedDocuments'); hover(actions, '.related-documents-tree-item__title', 4); }) ); 

Infelizmente, nessa opção, além da capacidade de selecionar a área da imagem, também perdemos a capacidade de usar ações padrão do mecanismo Gemini para trabalhar com elementos da árvore DOM (mouseDown (), mouseMove (), focus (), etc.), etc. para. Elementos dentro do quadro de Gêmeos não "veem". Mas ainda temos a oportunidade de usar a função executeJS (), com a qual você pode executar o código JavaScript em um contexto do navegador. Com base nessa função, implementamos os análogos de ações padrão de que precisamos, que já funcionam no contexto do quadro do Storybook. Aqui tivemos que "conjurar" um pouco para transferir valores de parâmetros do contexto de teste para o contexto do navegador - executeJS (), infelizmente, não oferece essa oportunidade. Portanto, à primeira vista, o código parece um pouco estranho - a função é convertida em uma string, parte do código é substituída por valores de parâmetros e, em ExecuteJs (), a função é restaurada da string usando eval ():

 function openStory(actions, storyName) { const storyNameLowered = storyName.toLowerCase(); const clickTo = function(window) { Array.from(window.document.querySelectorAll('a')).filter( function(el) { return el.textContent.toLowerCase() === 'storyNameLowered'; })[0].click(); }; actions.executeJS(eval(`(${clickTo.toString().replace('storyNameLowered', storyNameLowered)})`)); } function dispatchEvents(actions, targets, index, events) { const dispatch = function(window) { const document = window.document.querySelector('#storybook-preview-iframe').contentWindow.document; const target = document.querySelectorAll('targets')[index || 0]; events.forEach(function(event) { const clickEvent = document.createEvent('MouseEvents'); clickEvent.initEvent(event, true, true); target.dispatchEvent(clickEvent); }); }; actions.executeJS(eval(`(${dispatch.toString() .replace('targets', targets) .replace('index', index) .replace('events', `["${events.join('","')}"]`)})` )); } function hover(actions, selectors, index) { dispatchEvents(actions, selectors, index, [ 'mouseenter', 'mouseover' ]); } module.exports = { openStory: openStory, hover: hover }; 

Repetições de execução


Depois que os testes visuais foram escritos e começaram a funcionar, alguns dos testes não eram muito estáveis. Em algum lugar, o ícone não terá tempo para desenhar, em algum lugar a seleção não será removida e teremos uma incompatibilidade com a imagem de referência. Portanto, foi decidido incluir novos testes de execução de teste. No entanto, no Gemini, as tentativas repetidas para todo o conjunto e, como mencionado acima, tentamos evitar situações em que um conjunto é feito para cada cena - isso atrasa demais a execução dos testes. Por outro lado, quanto mais fotos são tiradas na estrutura de uma suíte, maior a probabilidade de que a execução repetida da suíte possa cair, bem como a anterior. Portanto, era necessário implementar novas tentativas. Em nosso esquema, a repetição da execução não é feita para todo o conjunto, mas apenas para as imagens que não passaram na execução com falha anterior. Para fazer isso, no manipulador de eventos TEST_RESULT, analisamos o resultado da comparação do instantâneo com o padrão e, para os instantâneos que não passaram na comparação, e apenas para eles, criamos um novo conjunto:

 const SuiteCollection = require('gemini/lib/suite-collection'); const Suite = require('gemini/lib/suite'); let retrySuiteCollection; let retryCount = 2; runner.on(runner.events.BEGIN, () => { retrySuiteCollection = new SuiteCollection(); }); runner.on(runner.events.TEST_RESULT, args => { const testId = `${args.state.name}/${args.suite.name}/${args.browserId}`; if (!args.equal) { if (retryCount > 0) console.log(chalk.yellow(`failed ${testId}`)); else console.log(chalk.red(`failed ${testId}`)); let suite = retrySuiteCollection.topLevelSuites().find(s => s.name === args.suite.name); if (!suite) { suite = new Suite(args.suite.name); suite.url = args.suite.url; suite.file = args.suite.file; suite.path = args.suite.path; suite.captureSelectors = [ ...args.suite.captureSelectors ]; suite.browsers = [ ...args.suite.browsers ]; suite.skipped = [ ...args.suite.skipped ]; suite.beforeActions = [ ...args.suite.beforeActions ]; retrySuiteCollection.add(suite); } if (!suite.states.find(s => s.name === args.state.name)) { suite.addState(args.state.clone()); } } else console.log(chalk.green(`passed ${testId}`)); }); 

A propósito, o evento TEST_RESULT também foi útil para visualizar o progresso dos testes à medida que eles passavam. Agora, o desenvolvedor não precisa esperar até que todos os testes sejam concluídos, ele pode interromper a execução se perceber que algo deu errado. Se a execução do teste for interrompida, o Gemini fechará corretamente as sessões do navegador abertas pelo servidor selenium.

Após a conclusão da execução do teste, se o novo conjunto não estiver vazio, execute-o até o número máximo de repetições estar esgotado:

 function onComplete(result) { if ((retryCount--) > 0 && result.failed > 0 && retrySuiteCollection.topLevelSuites().length > 0) { runner.test(retrySuiteCollection, {}).done(onComplete); } } runner.readTests(path).done(tests => runner.test(tests).done(onComplete)); 

Sumário


Hoje, temos cerca de cinquenta testes visuais cobrindo os principais estados visuais de nossa aplicação. Obviamente, não há necessidade de falar sobre a cobertura total dos testes de interface do usuário, mas ainda não definimos essa meta. Os testes funcionam com sucesso nas estações de trabalho dos desenvolvedores e nos agentes de construção. Embora os testes sejam realizados apenas no contexto do Chrome e do Internet Explorer, mas no futuro é possível conectar outros navegadores. Toda essa economia atende à grade do Selemium com dois nós implantados em máquinas virtuais.

Periodicamente, somos confrontados com o fato de que, após o lançamento da nova versão do Chrome, é necessário atualizar as imagens de referência, pois alguns elementos começaram a ser exibidos de maneira um pouco diferente (por exemplo, rolagem), mas não há nada a ser feito sobre isso. É raro, mas acontece que, ao alterar a estrutura de um armazenamento redux, é necessário recuperar novamente os estados salvos para os testes. Restaurar exatamente o mesmo estado que estava em teste no momento de sua criação, é claro, não é fácil. Como regra, ninguém já se lembra em qual banco de dados essas fotos foram tiradas e você precisa tirar uma nova foto com outros dados. Isso é um problema, mas não é um grande problema. Para resolvê-lo, você pode tirar fotos em uma base de demonstração, pois temos scripts para sua geração e são atualizados.

Source: https://habr.com/ru/post/pt454464/


All Articles