Desarrollo de pruebas visuales basadas en Gemini y Storybook.

Hola Habr! En este artículo quiero compartir la experiencia de desarrollar pruebas visuales en nuestro equipo.

Sucedió que no pensamos de inmediato en las pruebas de diseño. Bueno, algunos cuadros se moverán por un par de píxeles, bueno, arréglenlo. Al final, hay probadores: la mosca no volará más allá de ellos. Pero el factor humano todavía no se puede engañar: detectar cambios menores en la interfaz de usuario está lejos de ser siempre físicamente posible, incluso para un probador. La pregunta surgió cuando se comenzó la optimización seria del diseño y la transición a BEM. Aquí, ciertamente no habría sido sin pérdidas, y necesitábamos desesperadamente una forma automatizada de detectar situaciones cuando, como resultado de ediciones, algo en la interfaz de usuario comienza a cambiar no según lo previsto, o no como estaba previsto.

Cualquier desarrollador sabe acerca de las pruebas de código de unidad. Las pruebas unitarias dan confianza de que los cambios en el código no rompieron nada. Bueno, al menos no se rompieron en la parte para la que hay pruebas. El mismo principio se puede aplicar a la interfaz de usuario. Al igual que las clases de prueba de prueba, las pruebas visuales prueban los componentes visuales que conforman la interfaz de usuario de una aplicación.

Para los componentes visuales, puede escribir pruebas unitarias "clásicas", que, por ejemplo, inician la representación de componentes con diferentes valores de parámetros de entrada y comprueban el estado esperado del árbol DOM utilizando declaraciones de aserción, comparando elementos individuales o una instantánea del árbol DOM del componente con la referencia en general Las pruebas visuales también se basan en instantáneas, pero ya en instantáneas de la visualización del componente (capturas de pantalla). La esencia de la prueba visual es comparar la imagen tomada durante la prueba con la de referencia y, si se encuentran diferencias, aceptar la nueva imagen como referencia o corregir el error que causó estas diferencias.

Por supuesto, el "examen" de los componentes visuales individuales no es muy efectivo. Los componentes no viven en el vacío y su visualización puede depender de los componentes de nivel superior o de los vecinos. No importa cómo probamos los componentes individuales, la imagen en su conjunto puede tener defectos. Por otro lado, si toma imágenes de toda la ventana de la aplicación, muchas de las imágenes contendrán los mismos componentes, lo que significa que si cambia un componente, nos veremos obligados a actualizar todas las imágenes en las que este componente está presente.

La verdad, como de costumbre, está en algún lugar en el medio: puede dibujar la página completa de la aplicación, pero tomar una fotografía de solo un área bajo la cual se crea la prueba, en el caso particular, esta área puede coincidir con el área de un componente específico, pero esto no será un componente en vacío, pero en un entorno muy real. Y esto ya será similar a una prueba visual de la unidad, aunque difícilmente se puede decir acerca de la modularidad si la "unidad" sabe algo sobre el medio ambiente. Bueno, está bien, no es tan importante si la categoría de pruebas incluye pruebas visuales, modulares o de integración. Como dice el refrán, "¿revisas o te vas?"

Selección de herramienta


Para acelerar la ejecución de las pruebas, la representación de la página se puede hacer en un navegador sin cabeza que hace todo el trabajo en la memoria sin mostrarse en la pantalla y garantiza el máximo rendimiento. Pero en nuestro caso, fue fundamental garantizar que la aplicación funcionara en Internet Explorer (IE), que no tiene un modo sin cabeza, y necesitábamos una herramienta para administrar los navegadores mediante programación. Afortunadamente, todo ha sido inventado antes que nosotros y existe un instrumento de este tipo: se llama selenio . Como parte del proyecto Selenium, se están desarrollando controladores para administrar varios navegadores, incluido un controlador para IE. El servidor Selenium puede administrar navegadores no solo localmente, sino también de forma remota, formando un grupo de servidores de selenio, la llamada grilla de selenio.

El selenio es una herramienta poderosa, pero el umbral para ingresar es bastante alto. Decidimos buscar herramientas listas para usar para pruebas visuales basadas en selenio y encontramos un maravilloso producto de Yandex llamado Gemini . Gemini puede tomar fotografías, incluidas imágenes de un área determinada de la página, comparar imágenes con referencias, visualizar la diferencia y tener en cuenta momentos como el suavizado o un cursor parpadeante. Además, Gemini puede hacer repeticiones de pruebas caídas, paralelizar la ejecución de pruebas y muchas otras ventajas. En general, decidimos intentarlo.

Las pruebas de Géminis son fáciles de escribir. Primero debe preparar la infraestructura: instale selenium-standalone e inicie el servidor de selenium. Luego configure gemini, especificando la dirección de la aplicación bajo prueba (rootUrl), la dirección del servidor de selenio (gridUrl), la composición y configuración de los navegadores, así como los complementos necesarios para generar informes, optimizando la compresión de imágenes. Ejemplo de configuración:

//.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' } }; 

Las pruebas en sí mismas son una colección de suites, en cada una de las cuales se toman una o más imágenes (estados). Antes de tomar una instantánea (método de captura ()), puede establecer el área de la página que se tomará mediante el método setCaptureElements (), y también realizar algunas acciones preparatorias si es necesario en el contexto del navegador utilizando los métodos del objeto de acciones o utilizando un código JavaScript arbitrario - para Esto en acciones tiene un método executeJS ().

Un ejemplo:

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

Datos de prueba


Se eligió una herramienta de prueba, pero aún quedaba un largo camino hacia la solución final. Era necesario entender qué hacer con los datos que se muestran en las imágenes. Permítanme recordarles que en las pruebas decidimos no dibujar componentes individuales, sino toda la página de la aplicación, para probar los componentes visuales no en el vacío, sino en el entorno real de otros componentes. Si necesita transferir los datos de prueba necesarios a los accesorios ee (estoy hablando de componentes de reacción) para renderizar un componente individual, se necesita mucho más para renderizar toda la página de la aplicación, y preparar el entorno para tal prueba puede ser un dolor de cabeza.

Por supuesto, puede dejar que la aplicación misma reciba datos para que durante la prueba ejecute solicitudes al backend, que, a su vez, recibiría datos de algún tipo de base de datos de referencia, pero ¿qué pasa con el control de versiones? No puedes poner una base de datos en un repositorio git. No, por supuesto que puedes, pero hay algunas deficiencias.

Alternativamente, para ejecutar pruebas, puede reemplazar el servidor de fondo real con uno falso, lo que le daría a la aplicación web no datos de la base de datos, sino datos estáticos almacenados, por ejemplo, en formato json, ya con las fuentes. Sin embargo, la preparación de tales datos tampoco es demasiado trivial. Decidimos seguir el camino más fácil: no extraer los datos del servidor, sino simplemente restaurar el estado de la aplicación (en nuestro caso, el estado del almacenamiento redux ), que estaba en la aplicación en el momento en que se tomó la imagen de referencia, antes de ejecutar la prueba.

Para serializar el estado actual de la tienda redux, se ha agregado el método snapshot () al objeto de ventana:

 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 la línea de comandos de la consola del navegador, puede guardar el estado actual del almacenamiento redux en un archivo:

imagen

Como infraestructura para las pruebas visuales, se eligió Storybook , una herramienta para el desarrollo interactivo de bibliotecas de componentes visuales. La idea principal era que, en lugar de los diversos estados de los componentes en el árbol de historias, arreglara los diversos estados de nuestra aplicación y los utilizara para tomar capturas de pantalla. Al final, no hay una diferencia fundamental entre componentes simples y complejos, excepto en la preparación del medio ambiente.

Por lo tanto, cada prueba visual es una historia, antes de mostrar el estado del almacenamiento redux previamente guardado en el archivo. Esto se hace usando el componente Proveedor de la biblioteca react-redux, a la propiedad de la tienda de la cual se pasa el estado deserializado restaurado del archivo guardado previamente:

 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> ); }); 

En el ejemplo anterior, ContextContainer es un componente que incluye el "esqueleto" de la aplicación: el árbol de navegación, el encabezado y el área de contenido. En el área de contenido, se pueden representar varios componentes (lista, tarjeta, diálogo, etc.) según el estado actual del almacenamiento redux. Para que el componente no cumpla solicitudes innecesarias al backend para la entrada, se le pasan las propiedades de código auxiliar correspondientes.

En el contexto de un Storybook, se ve más o menos así:

imagen

Libro de cuentos Gemini +


Entonces, descubrimos los datos para las pruebas. La siguiente tarea es hacer amigos con Gemini y Storybook. A primera vista, todo es simple: en la configuración de Gemini especificamos la dirección de la aplicación bajo prueba. En nuestro caso, esta es la dirección del servidor de Storybook. Solo necesita elevar el servidor del libro de cuentos antes de comenzar las pruebas de gemini. Puede hacerlo directamente desde el código utilizando la suscripción de evento Gemini START_RUNNER y 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 para las pruebas, utilizamos http-server, que devuelve el contenido de la carpeta con el libro de cuentos ensamblado estáticamente (para construir el libro de cuentos estático, use el comando build-storybook ).

Hasta ahora, todo ha ido bien, pero los problemas no se han hecho esperar. El hecho es que el libro de cuentos muestra la historia dentro del marco. Inicialmente, queríamos poder establecer la región selectiva de la imagen usando setCaptureElements (), pero esto solo se puede hacer si especifica la dirección del marco como la dirección del conjunto, algo como esto:

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

Pero luego resulta que para cada toma debemos crear nuestra propia suite, porque La URL se puede configurar para la suite en su conjunto, pero no para una sola instantánea dentro de la suite. Debe entenderse que cada suite se ejecuta en una sesión de navegador separada. Esto, en principio, es correcto: las pruebas no deberían depender unas de otras, pero abrir una sesión de navegador separada y la posterior carga del Storybook lleva bastante tiempo, mucho más que simplemente moverse a través de las historias en el marco del Storybook ya abierto. Por lo tanto, con un gran número de suites, el tiempo de ejecución de la prueba es muy lento. Parte del problema puede resolverse paralelizando la ejecución de las pruebas, pero la paralelización consume muchos recursos (memoria, procesador). Por lo tanto, habiendo decidido ahorrar en recursos y al mismo tiempo no perder demasiado en la duración de la ejecución de la prueba, nos negamos a abrir el marco en una ventana separada del navegador. Las pruebas se realizan dentro de una sola sesión del navegador, pero antes de cada disparo, la siguiente historia se carga en el marco como si simplemente abriéramos el libro de historias y hiciéramos clic en nodos individuales en el árbol de historias. Área de imagen - fotograma completo:

 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); }) ); 

Desafortunadamente, en esta opción, además de la capacidad de seleccionar el área de la imagen, también perdimos la capacidad de usar acciones estándar del motor Gemini para trabajar con elementos del árbol DOM (mouseDown (), mouseMove (), focus (), etc.), etc. a. Los elementos dentro del marco de Gemini no "ven". Pero aún tenemos la oportunidad de usar la función executeJS (), con la que puede ejecutar código JavaScript en un contexto de navegador. En función de esta función, implementamos los análogos de las acciones estándar que necesitamos, que ya funcionan en el contexto del marco de Storybook. Aquí tuvimos que "evocar" un poco para transferir los valores de los parámetros del contexto de prueba al contexto del navegador. Lamentablemente, executeJS () no brinda esa oportunidad. Por lo tanto, a primera vista, el código parece un poco extraño: la función se traduce en una cadena, parte del código se reemplaza con valores de parámetros y en ExecuteJs () la función se restaura de la cadena 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 }; 

Repeticiones de ejecución


Después de que se escribieron las pruebas visuales y comenzaron a funcionar, resultó que algunas de las pruebas no eran muy estables. En algún lugar, el icono no tendrá tiempo para dibujar, en algún lugar la selección no se eliminará y obtendremos una discrepancia con la imagen de referencia. Por lo tanto, se decidió incluir nuevas pruebas de ejecución de la prueba. Sin embargo, en Gemini, los reintentos funcionan para toda la suite y, como se mencionó anteriormente, tratamos de evitar situaciones en las que se crea una suite para cada disparo; esto ralentiza demasiado la ejecución de las pruebas. Por otro lado, cuantas más tomas se tomen dentro del marco de una suite, mayor es la probabilidad de que la ejecución repetida de la suite caiga tan bien como la anterior. Por lo tanto, fue necesario implementar reintentos. En nuestro esquema, la repetición de la ejecución no se realiza para toda la suite, sino solo para aquellas imágenes que no pasaron en la ejecución fallida anterior. Para hacer esto, en el controlador de eventos TEST_RESULT, analizamos el resultado de comparar la instantánea con el estándar, y para las instantáneas que no pasaron la comparación, y solo para ellas, creamos un nuevo 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}`)); }); 

Por cierto, el evento TEST_RESULT también fue útil para visualizar el progreso de las pruebas a medida que pasaban. Ahora el desarrollador no necesita esperar hasta que se completen todas las pruebas, puede interrumpir la ejecución si ve que algo salió mal. Si se interrumpe la ejecución de la prueba, Gemini cerrará correctamente las sesiones de navegador abiertas por el servidor de selenio.

Al finalizar la ejecución de la prueba, si el nuevo conjunto no está vacío, ejecútelo hasta que se agote el número máximo de repeticiones:

 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)); 

Resumen


Hoy tenemos alrededor de cincuenta pruebas visuales que cubren los principales estados visuales de nuestra aplicación. Por supuesto, no hay necesidad de hablar sobre la cobertura total de las pruebas de IU, pero aún no hemos establecido ese objetivo. Las pruebas funcionan con éxito tanto en las estaciones de trabajo de los desarrolladores como en los agentes de compilación. Si bien las pruebas se realizan solo en el contexto de Chrome e Internet Explorer, en el futuro es posible conectar otros navegadores. Toda esta economía sirve a la red Selemium con dos nodos implementados en máquinas virtuales.

De vez en cuando, nos enfrentamos con el hecho de que después del lanzamiento de la nueva versión de Chrome es necesario actualizar las imágenes de referencia debido al hecho de que algunos elementos comenzaron a mostrarse de manera un poco diferente (por ejemplo, desplazadores), pero no hay nada que hacer al respecto. Es raro, pero sucede que al cambiar la estructura de un almacenamiento redux, debe recuperar los estados guardados para las pruebas. Por supuesto, restaurar exactamente el mismo estado que estaba en la prueba en el momento de su creación no es fácil. Como regla general, nadie recuerda en qué base de datos se tomaron estas imágenes y debe tomar una nueva imagen con otros datos. Este es un problema, pero no grande. Para resolverlo, puede tomar fotografías en una base de demostración, ya que tenemos scripts para su generación y estamos actualizados.

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


All Articles