Bonjour, Habr! Dans cet article, je veux partager l'expérience du développement de tests visuels dans notre équipe.
Il se trouve que nous n'avons pas immédiatement pensé aux tests de mise en page. Eh bien, une partie du cadre se déplacera sur quelques pixels, enfin, corrigez-la. En fin de compte, il y a des testeurs - la mouche ne passera pas devant eux. Mais le facteur humain ne peut toujours pas être dupe - détecter des changements mineurs dans l'interface utilisateur est loin d'être toujours physiquement possible même pour un testeur. La question s'est posée quand une optimisation sérieuse de la mise en page et de la transition vers BEM a commencé. Ici, cela n'aurait certainement pas été sans perte, et nous avions désespérément besoin d'un moyen automatisé pour détecter les situations où, à la suite de modifications, quelque chose dans l'interface utilisateur commence à changer pas comme prévu, ou pas là où il était prévu.
Tout développeur connaît les tests de code unitaire. Les tests unitaires donnent l'assurance que les changements dans le code n'ont rien cassé. Eh bien, au moins, ils n'ont pas cassé la partie pour laquelle il y a des tests. Le même principe peut être appliqué à l'interface utilisateur. Tout comme les tests unitaires testent les classes, les tests visuels testent les composants visuels qui composent l'interface utilisateur d'une application.
Pour les composants visuels, vous pouvez écrire des tests unitaires «classiques» qui, par exemple, lancent le rendu des composants avec différentes valeurs de paramètres d'entrée et vérifient l'état attendu de l'arborescence DOM à l'aide des instructions assert, en comparant des éléments individuels ou un instantané de l'arborescence DOM du composant avec la référence en général. Les tests visuels sont également basés sur des instantanés, mais déjà sur des instantanés de l'affichage visuel du composant (captures d'écran). L'essence du test visuel est de comparer l'image prise pendant le test avec celle de référence et, si des différences sont trouvées, d'accepter la nouvelle image comme référence ou de corriger le bogue à l'origine de ces différences.
Bien sûr, le «filtrage» des composants visuels individuels n'est pas très efficace. Les composants ne vivent pas dans le vide et leur affichage peut dépendre soit des composants de niveau supérieur, soit des composants voisins. Peu importe la façon dont nous testons les composants individuels, l'image dans son ensemble peut présenter des défauts. D'un autre côté, si vous prenez des photos de toute la fenêtre de l'application, alors la plupart des images contiendront les mêmes composants, ce qui signifie que si vous changez un composant, nous serons obligés de mettre à jour toutes les images dans lesquelles ce composant est présent.
La vérité, comme d'habitude, se situe quelque part au milieu - vous pouvez dessiner la page entière de l'application, mais prenez une photo d'une seule zone sous laquelle le test est créé, dans le cas particulier, cette zone peut coïncider avec la zone d'un composant spécifique, mais ce ne sera pas un composant vide, mais dans un environnement très réel. Et cela sera déjà similaire à un test visuel unitaire, bien que l'on puisse difficilement en dire autant sur la modularité si «l'unité» sait quelque chose sur l'environnement. Bon, d'accord, ce n'est pas si important que la catégorie de tests inclue des tests visuels - modulaires ou d'intégration. Comme le dit le proverbe, "vous vérifiez ou partez?"
Sélection d'outils
Pour accélérer l'exécution des tests, le rendu des pages peut être effectué dans un
navigateur sans tête qui fait tout le travail en mémoire sans être affiché à l'écran et garantit des performances maximales. Mais dans notre cas, il était essentiel de s'assurer que l'application fonctionne dans Internet Explorer (IE), qui n'a pas de mode sans tête, et nous avions besoin d'un outil de gestion de navigateur programmatique. Heureusement, tout a déjà été inventé avant nous et il existe un tel instrument - il s'appelle le
sélénium . Dans le cadre du projet Selenium, des pilotes sont en cours de développement pour gérer différents navigateurs, dont un pilote pour IE. Le serveur Selenium peut gérer les navigateurs non seulement localement, mais également à distance, formant un cluster de serveurs sélénium, la soi-disant grille de sélénium.
Le sélénium est un outil puissant, mais le seuil pour y entrer est assez élevé. Nous avons décidé de chercher des outils prêts à l'emploi pour les tests visuels basés sur le sélénium et sommes tombés sur un merveilleux produit de Yandex appelé
Gemini . Les Gémeaux peuvent prendre des photos, y compris des photos d'une certaine zone de la page, comparer des photos avec des références, visualiser la différence et prendre en compte des moments tels que l'anticrénelage ou un curseur clignotant. De plus, Gemini peut effectuer des réexécutions de tests supprimés, paralléliser l'exécution de tests et de nombreux autres goodies. En général, nous avons décidé d'essayer.
Les tests Gemini sont faciles à écrire. Vous devez d'abord préparer l'infrastructure - installez
selenium-standalone et démarrez le serveur selenium. Configurez ensuite gemini, en spécifiant l'adresse de l'application en cours de test (rootUrl), l'adresse du serveur sélénium (gridUrl), la composition et la configuration des navigateurs, ainsi que les plugins nécessaires pour générer des rapports, optimiser la compression d'image. Exemple de configuration:
Les tests eux-mêmes sont une collection de suites, dans chacune desquelles une ou plusieurs photos (états) sont prises. Avant de prendre un instantané (méthode capture ()), vous pouvez définir la zone de la page à photographier à l'aide de la méthode setCaptureElements (), et également effectuer des actions préparatoires si nécessaire dans le contexte du navigateur en utilisant les méthodes de l'objet actions ou en utilisant du code JavaScript arbitraire - pour ceci dans les actions a une méthode executeJS ().
Un exemple:
gemini.suite('login-dialog', suite => { suite.setUrl('/') .setCaptureElements('.login__form') .capture('default'); .capture('focused', actions => actions.focus('.login__editor')); });
Données de test
Un outil de test a été choisi, mais c'était encore loin de la solution finale. Il était nécessaire de comprendre quoi faire avec les données affichées dans les images. Permettez-moi de vous rappeler que dans les tests, nous avons décidé de dessiner non pas des composants individuels, mais la page entière de l'application afin de tester les composants visuels non pas dans le vide, mais dans l'environnement réel d'autres composants. Si vous devez transférer les données de test nécessaires aux
accessoires ee (je parle de composants React) pour rendre un composant individuel, beaucoup plus est nécessaire pour rendre la page entière de l'application, et préparer l'environnement pour un tel test peut être un casse-tête.
Bien sûr, vous pouvez laisser l'application elle-même pour recevoir les données, de sorte qu'au cours du test, elle exécutera des requêtes vers le backend, qui, à son tour, recevrait les données d'une sorte de base de données de référence, mais qu'en est-il du versioning? Vous ne pouvez pas placer une base de données dans un référentiel git. Non, bien sûr que vous pouvez, mais il y a des décences.
Alternativement, pour exécuter des tests, vous pouvez remplacer le vrai serveur principal par un faux, ce qui donnerait à l'application Web non pas des données de la base de données, mais des données statiques stockées, par exemple, au format json, déjà avec les sources. Cependant, la préparation de ces données n'est pas non plus trop triviale. Nous avons décidé d'aller plus facilement - non pas pour extraire les données du serveur, mais simplement pour restaurer l'état de l'application (dans notre cas, l'état du
stockage redux ), qui était dans l'application au moment où l'image de référence a été prise, avant d'exécuter le test.
Pour sérialiser l'état actuel du magasin redux, la méthode snapshot () a été ajoutée à l'objet window:
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); };
En utilisant cette méthode, en utilisant la ligne de commande de la console du navigateur, vous pouvez enregistrer l'état actuel du stockage redux dans un fichier:

En tant qu'infrastructure pour les tests visuels,
Storybook a été choisi - un outil pour le développement interactif de bibliothèques de composants visuels. L'idée principale était qu'au lieu des différents états des composants dans l'arbre des histoires, corrigez les différents états de notre application et utilisez ces états pour prendre des captures d'écran. Au final, il n'y a pas de différence fondamentale entre les composants simples et complexes, sauf dans la préparation de l'environnement.
Ainsi, chaque test visuel est une histoire, avant le rendu dont l'état du stockage redux précédemment enregistré dans le fichier est restauré. Cela se fait à l'aide du composant Provider de la bibliothèque react-redux, à la propriété store dont l'état désérialisé restauré à partir du fichier précédemment enregistré est transmis:
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> ); });
Dans l'exemple ci-dessus, ContextContainer est un composant qui inclut le "squelette" de l'application - l'arborescence de navigation, l'en-tête et la zone de contenu. Dans la zone de contenu, différents composants peuvent être rendus (liste, carte, boîte de dialogue, etc.) en fonction de l'état actuel du stockage redux. Pour que le composant ne réponde pas aux demandes d'entrée inutiles du backend, les propriétés de stub correspondantes lui sont transmises.
Dans le contexte d'un livre de contes, cela ressemble à ceci:

Gemini + storybook
Nous avons donc trouvé les données pour les tests. La prochaine tâche consiste à se faire des amis Gemini et Storybook. À première vue, tout est simple - dans la configuration Gemini, nous spécifions l'adresse de l'application testée. Dans notre cas, il s'agit de l'adresse du serveur Storybook. Il vous suffit d'augmenter le serveur du livre d'histoires avant de commencer les tests gemini. Vous pouvez le faire directement à partir du code en utilisant l'abonnement à l'événement Gemini START_RUNNER et 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));
En tant que serveur pour les tests, nous avons utilisé http-server, qui renvoie le contenu du dossier avec le livre de contes statique (pour construire le livre de contes statique, utilisez la
commande build-storybook ).
Jusqu'à présent, tout s'est bien passé, mais les problèmes ne se sont pas fait attendre. Le fait est que le livre de contes affiche l'histoire à l'intérieur du cadre. Initialement, nous voulions pouvoir définir la région sélective de l'image à l'aide de setCaptureElements (), mais cela ne peut être fait que si vous spécifiez l'adresse du cadre comme adresse pour la suite, quelque chose comme ceci:
gemini.suite('VisualRegression', suite => suite.setUrl('http://localhost:6006/iframe.html?selectedKind=regression%2Fcards&selectedStory=IncomingLetter') .setCaptureElements('.some-component') .capture('IncomingLetter') );
Mais il s'avère que pour chaque plan, nous devons créer notre propre suite, car L'URL peut être définie pour la suite dans son ensemble, mais pas pour un instantané unique dans la suite. Il faut comprendre que chaque suite s'exécute dans une session de navigateur distincte. Ceci, en principe, est correct - les tests ne devraient pas dépendre les uns des autres, mais l'ouverture d'une session de navigateur distincte et le chargement subséquent du livre d'histoires prennent beaucoup de temps, bien plus que de simplement parcourir les histoires dans le cadre du livre d'histoires déjà ouvert. Par conséquent, avec un grand nombre de suites, le temps d'exécution du test est très lent. Une partie du problème peut être résolue en parallélisant l'exécution des tests, mais la parallélisation consomme beaucoup de ressources (mémoire, processeur). Par conséquent, ayant décidé d'économiser sur les ressources et en même temps de ne pas trop perdre dans la durée du test, nous avons refusé d'ouvrir le cadre dans une fenêtre de navigateur distincte. Les tests sont effectués dans une seule session de navigateur, mais avant chaque prise de vue, l'histoire suivante est chargée dans le cadre comme si nous ouvrions simplement le livre d'histoires et cliquions sur des nœuds individuels dans l'arborescence des histoires. Zone d'image - cadre entier:
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); }) );
Malheureusement, dans cette option, en plus de la possibilité de sélectionner la zone d'image, nous avons également perdu la possibilité d'utiliser des actions standard du moteur Gemini pour travailler avec des éléments de l'arborescence DOM (mouseDown (), mouseMove (), focus (), etc.), etc. à. Les éléments du cadre Gemini ne «voient» pas. Mais nous avons toujours la possibilité d'utiliser la fonction executeJS (), avec laquelle vous pouvez exécuter du code JavaScript dans un contexte de navigateur. Sur la base de cette fonction, nous avons implémenté les analogues d'actions standard dont nous avons besoin, qui fonctionnent déjà dans le contexte du cadre Storybook. Ici, nous avons dû «conjurer» un peu afin de transférer les valeurs des paramètres du contexte de test au contexte du navigateur - executeJS (), malheureusement, ne fournit pas une telle opportunité. Par conséquent, à première vue, le code semble un peu étrange - la fonction est traduite en chaîne, une partie du code est remplacée par des valeurs de paramètre, et dans ExecuteJs () la fonction est restaurée à partir de la chaîne en utilisant 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 };
Répétitions d'exécution
Après que les tests visuels ont été écrits et ont commencé à fonctionner, il s'est avéré que certains des tests n'étaient pas très stables. Quelque part, l'icône n'aura pas le temps de dessiner, quelque part la sélection ne sera pas supprimée et nous obtenons un décalage avec l'image de référence. Par conséquent, il a été décidé d'inclure de nouveaux tests d'exécution des tests. Cependant, dans Gemini, les tentatives fonctionnent pour toute la suite, et comme mentionné ci-dessus, nous avons essayé d'éviter les situations où une suite est faite pour chaque prise de vue - cela ralentit trop l'exécution des tests. En revanche, plus il y a de prises de vue dans le cadre d'une suite, plus il y a de chances que l'exécution répétée de la suite tombe ainsi que la précédente. Par conséquent, il était nécessaire d'implémenter de nouvelles tentatives. Dans notre schéma, la répétition de l'exécution n'est pas effectuée pour l'ensemble de la suite, mais uniquement pour les images qui n'ont pas été transmises lors de l'exécution précédente. Pour ce faire, dans le gestionnaire d'événements TEST_RESULT, nous analysons le résultat de la comparaison de l'instantané avec la norme, et pour les instantanés qui n'ont pas réussi la comparaison, et seulement pour eux, nous créons une nouvelle suite:
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}`)); });
Soit dit en passant, l'événement TEST_RESULT a également été utile pour visualiser la progression des tests au fur et à mesure de leur réussite. Maintenant, le développeur n'a pas besoin d'attendre que tous les tests soient terminés, il peut interrompre l'exécution s'il voit que quelque chose s'est mal passé. Si l'exécution du test est interrompue, Gemini fermera correctement les sessions du navigateur ouvertes par le serveur sélénium.
Une fois le test terminé, si la nouvelle suite n'est pas vide, exécutez-la jusqu'à ce que le nombre maximal de répétitions soit épuisé:
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));
Résumé
Nous avons aujourd'hui une cinquantaine de tests visuels couvrant les principaux états visuels de notre application. Bien sûr, il n'est pas nécessaire de parler de la couverture complète des tests d'interface utilisateur, mais nous n'avons pas encore fixé un tel objectif. Les tests fonctionnent avec succès à la fois sur les postes de travail des développeurs et sur les agents de build. Bien que les tests soient effectués uniquement dans le contexte de Chrome et d'Internet Explorer, mais à l'avenir, il est possible de connecter d'autres navigateurs. Toute cette économie dessert le réseau Selemium avec deux nœuds déployés sur des machines virtuelles.
De temps en temps, nous sommes confrontés au fait qu'après la sortie de la nouvelle version de Chrome il est nécessaire de mettre à jour les images de référence car certains éléments ont commencé à apparaître un peu différemment (par exemple, les scrollers), mais il n'y a rien à faire. C'est rare, mais il arrive que lorsque vous modifiez la structure d'un magasin de redux, vous devez récupérer à nouveau les états enregistrés pour les tests. Il n'est bien sûr pas facile de restaurer exactement le même état que celui qui était à l'essai au moment de sa création. En règle générale, personne ne se souvient déjà de la base de données sur laquelle ces photos ont été prises et vous devez prendre une nouvelle photo sur d'autres données. C'est un problème, mais pas un gros. Pour le résoudre, vous pouvez prendre des photos sur une base de démonstration, car nous avons des scripts pour sa génération et nous sommes tenus à jour.