Hallo Habr! In diesem Artikel möchte ich die Erfahrungen mit der Entwicklung visueller Tests in unserem Team teilen.
So kam es, dass wir nicht sofort über Layout-Tests nachdachten. Nun, ein Frame bewegt sich für ein paar Pixel nach außen. Am Ende gibt es Tester - die Fliege fliegt nicht an ihnen vorbei. Der menschliche Faktor lässt sich jedoch immer noch nicht täuschen - das Erkennen geringfügiger Änderungen in der Benutzeroberfläche ist selbst für einen Tester bei weitem nicht immer physisch möglich. Die Frage stellte sich, als mit einer ernsthaften Optimierung des Layouts und dem Übergang zu BEM begonnen wurde. Hier wäre es sicherlich nicht verlustfrei gewesen, und wir brauchten dringend eine automatisierte Methode, um Situationen zu erkennen, in denen sich aufgrund von Änderungen etwas in der Benutzeroberfläche nicht wie beabsichtigt oder nicht dort ändert, wo es beabsichtigt war.
Jeder Entwickler kennt sich mit Unit-Code-Tests aus. Unit-Tests geben die Gewissheit, dass Änderungen im Code nichts kaputt gemacht haben. Nun, zumindest haben sie den Teil, für den es Tests gibt, nicht gebrochen. Das gleiche Prinzip kann auf die Benutzeroberfläche angewendet werden. So wie Unit-Tests Testklassen testen, testen visuelle Tests die visuellen Komponenten, aus denen die Benutzeroberfläche einer Anwendung besteht.
Für visuelle Komponenten können Sie „klassische“ Komponententests schreiben, die beispielsweise das Rendern von Komponenten mit unterschiedlichen Werten von Eingabeparametern initiieren und den erwarteten Status des DOM-Baums mithilfe von assert-Anweisungen überprüfen, indem Sie entweder einzelne Elemente oder eine Momentaufnahme des DOM-Baums der Komponente mit der Referenz vergleichen im Allgemeinen. Visuelle Tests basieren ebenfalls auf Schnappschüssen, jedoch bereits auf Schnappschüssen der visuellen Anzeige der Komponente (Screenshots). Der Kern des visuellen Tests besteht darin, das während des Tests aufgenommene Bild mit dem Referenzbild zu vergleichen und, wenn Unterschiede festgestellt werden, entweder das neue Bild als Referenzbild zu akzeptieren oder den Fehler zu beheben, der diese Unterschiede verursacht hat.
Natürlich ist das „Screening“ einzelner visueller Komponenten nicht sehr effektiv. Die Komponenten leben nicht in einem Vakuum und ihre Anzeige kann entweder von den Komponenten der obersten Ebene oder von benachbarten Komponenten abhängen. Unabhängig davon, wie wir einzelne Komponenten testen, kann das gesamte Bild Mängel aufweisen. Wenn Sie dagegen Bilder des gesamten Anwendungsfensters aufnehmen, enthalten viele der Bilder dieselben Komponenten. Wenn Sie also eine Komponente ändern, müssen wir alle Bilder aktualisieren, in denen diese Komponente vorhanden ist.
Die Wahrheit liegt wie üblich irgendwo in der Mitte - Sie können die gesamte Seite der Anwendung zeichnen, aber nur einen Bereich fotografieren, unter dem der Test erstellt wird. In dem speziellen Fall kann dieser Bereich mit dem Bereich einer bestimmten Komponente übereinstimmen, dies ist jedoch keine Komponente in Vakuum, aber in einer sehr realen Umgebung. Und dies wird bereits einem visuellen Einheitentest ähneln, obwohl über Modularität kaum etwas gesagt werden kann, wenn die „Einheit“ etwas über die Umgebung weiß. Okay, es ist nicht so wichtig, ob die Kategorie der Tests visuelle Tests umfasst - modular oder integriert. Wie das Sprichwort sagt: "Sie überprüfen oder gehen?"
Werkzeugauswahl
Um die Ausführung von Tests zu beschleunigen, kann das Rendern von Seiten in einem
kopflosen Browser durchgeführt werden , der die gesamte Arbeit im Speicher erledigt, ohne auf dem Bildschirm angezeigt zu werden, und maximale Leistung bietet. In unserem Fall war es jedoch wichtig sicherzustellen, dass die Anwendung in Internet Explorer (IE) funktioniert, das keinen Headless-Modus hat, und wir brauchten ein Tool zum programmgesteuerten Verwalten von Browsern. Glücklicherweise wurde bereits alles vor uns erfunden und es gibt ein solches Instrument - es heißt
Selen . Im Rahmen des Selenium-Projekts werden Treiber für die Verwaltung verschiedener Browser entwickelt, einschließlich eines Treibers für den Internet Explorer. Selenium Server können Browser nicht nur lokal, sondern auch remote verwalten und bilden einen Cluster von Selenium Servern, das sogenannte Selenium Grid.
Selen ist ein mächtiges Werkzeug, aber die Schwelle für den Eintritt ist ziemlich hoch. Wir haben uns entschlossen, nach vorgefertigten Werkzeugen für visuelle Tests auf der Basis von Selen zu suchen, und sind auf ein wunderbares Produkt von Yandex namens
Gemini gestoßen . Zwillinge können Bilder aufnehmen, einschließlich Bilder eines bestimmten Bereichs der Seite, Bilder mit Referenzbildern vergleichen, den Unterschied visualisieren und Momente wie Anti-Aliasing oder einen blinkenden Cursor berücksichtigen. Darüber hinaus kann Gemini gefallene Tests wiederholen, die Ausführung von Tests parallelisieren und viele andere Extras. Im Allgemeinen haben wir beschlossen, es zu versuchen.
Zwillingstests sind einfach zu schreiben. Zuerst müssen Sie die Infrastruktur vorbereiten -
Selenium-Standalone installieren und den Selenium-Server starten. Konfigurieren Sie anschließend gemini, geben Sie die Adresse der zu testenden Anwendung (rootUrl), die Adresse des Selenservers (gridUrl), die Zusammensetzung und Konfiguration der Browser sowie die erforderlichen Plugins zum Generieren von Berichten an und optimieren Sie die Bildkomprimierung. Konfigurationsbeispiel:
Die Tests selbst sind eine Sammlung von Suiten, in denen jeweils ein oder mehrere Bilder (Zustände) aufgenommen werden. Bevor Sie einen Snapshot (Capture () -Methode) erstellen, können Sie den Bereich der zu fotografierenden Seite mit der setCaptureElements () -Methode festlegen und gegebenenfalls im Browserkontext einige vorbereitende Aktionen ausführen, indem Sie entweder die Methoden des Aktionsobjekts oder beliebigen JavaScript-Code verwenden - für Dies in Aktionen hat eine executeJS () -Methode.
Ein Beispiel:
gemini.suite('login-dialog', suite => { suite.setUrl('/') .setCaptureElements('.login__form') .capture('default'); .capture('focused', actions => actions.focus('.login__editor')); });
Testdaten
Ein Testwerkzeug wurde ausgewählt, aber es war noch ein langer Weg bis zur endgültigen Lösung. Es war notwendig zu verstehen, was mit den auf den Bildern angezeigten Daten zu tun ist. Ich möchte Sie daran erinnern, dass wir uns bei den Tests entschieden haben, nicht einzelne Komponenten, sondern die gesamte Seite der Anwendung zu zeichnen, um die visuellen Komponenten nicht im Vakuum, sondern in der realen Umgebung anderer Komponenten zu testen. Wenn Sie die erforderlichen Testdaten an ee-
Requisiten übertragen müssen (ich spreche von Reaktionskomponenten), um eine einzelne Komponente zu rendern, ist viel mehr erforderlich, um die gesamte Seite der Anwendung zu rendern, und die Vorbereitung der Umgebung für einen solchen Test kann Kopfschmerzen bereiten.
Natürlich können Sie die Anwendung selbst verlassen, um die Daten zu empfangen, sodass sie während des Tests Anforderungen an das Backend ausführt, die wiederum Daten aus einer Art Referenzdatenbank empfangen würden, aber was ist mit der Versionierung? Sie können keine Datenbank in ein Git-Repository stellen. Nein, natürlich können Sie, aber es gibt einige Anstand.
Um Tests auszuführen, können Sie alternativ den realen Backend-Server durch einen gefälschten ersetzen, wodurch die Webanwendung keine Daten aus der Datenbank erhält, sondern statische Daten, die beispielsweise im JSON-Format bereits mit den Quellen gespeichert sind. Die Aufbereitung solcher Daten ist jedoch auch nicht zu trivial. Wir haben uns für den einfacheren Weg entschieden - nicht die Daten vom Server abzurufen, sondern einfach den Status der Anwendung (in unserem Fall den Status des
Redux-Speichers ) wiederherzustellen, der sich zum Zeitpunkt der Aufnahme des Referenzbilds in der Anwendung befand, bevor der Test ausgeführt wurde.
Um den aktuellen Status des Redux-Speichers zu serialisieren, wurde dem Fensterobjekt die Methode snapshot () hinzugefügt:
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); };
Mit dieser Methode können Sie über die Befehlszeile der Browserkonsole den aktuellen Status des Redux-Speichers in einer Datei speichern:

Als Infrastruktur für visuelle Tests wurde
Storybook ausgewählt - ein Tool für die interaktive Entwicklung von Bibliotheken visueller Komponenten. Die Hauptidee war, anstelle der verschiedenen Zustände der Komponenten im Storys-Baum die verschiedenen Zustände unserer Anwendung zu korrigieren und diese Zustände zum Erstellen von Screenshots zu verwenden. Letztendlich gibt es keinen grundlegenden Unterschied zwischen einfachen und komplexen Komponenten, außer bei der Vorbereitung der Umgebung.
Jeder visuelle Test ist also eine Geschichte, vor deren Rendern der Status des zuvor in der Datei gespeicherten Redux-Speichers wiederhergestellt wird. Dies erfolgt mithilfe der Provider-Komponente aus der React-Redux-Bibliothek, an deren Store-Eigenschaft der aus der zuvor gespeicherten Datei wiederhergestellte deserialisierte Status übergeben wird:
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> ); });
Im obigen Beispiel ist ContextContainer eine Komponente, die das "Grundgerüst" der Anwendung enthält - den Navigationsbaum, den Header und den Inhaltsbereich. Im Inhaltsbereich können je nach aktuellem Status des Redux-Speichers verschiedene Komponenten gerendert werden (Liste, Karte, Dialog usw.). Damit die Komponente keine unnötigen Anforderungen an das Backend zur Eingabe erfüllt, werden entsprechende Stub-Eigenschaften an sie übergeben.
Im Kontext eines Storybooks sieht es ungefähr so aus:

Zwillinge + Märchenbuch
Also haben wir die Daten für die Tests herausgefunden. Die nächste Aufgabe ist es, sich mit Gemini und Storybook anzufreunden. Auf den ersten Blick ist alles einfach - in der Gemini-Konfiguration geben wir die Adresse der zu testenden Anwendung an. In unserem Fall ist dies die Adresse des Storybook-Servers. Sie müssen nur den Storybook-Server hochfahren, bevor Sie mit den Gemini-Tests beginnen. Sie können dies direkt aus dem Code heraus mit dem Gemini-Ereignisabonnement START_RUNNER und END_RUNNER tun:
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));
Als Server für Tests haben wir den http-Server verwendet, der den Inhalt des Ordners mit dem statisch zusammengestellten Storybook zurückgibt (um das statische Storybook zu erstellen, verwenden Sie den
Befehl build-storybook ).
Bisher lief alles reibungslos, aber die Probleme haben sich nicht warten lassen. Tatsache ist, dass das Bilderbuch die Geschichte innerhalb des Rahmens anzeigt. Ursprünglich wollten wir den selektiven Bereich des Bildes mit setCaptureElements () festlegen können. Dies ist jedoch nur möglich, wenn Sie die Frame-Adresse als Adresse für die Suite angeben.
gemini.suite('VisualRegression', suite => suite.setUrl('http://localhost:6006/iframe.html?selectedKind=regression%2Fcards&selectedStory=IncomingLetter') .setCaptureElements('.some-component') .capture('IncomingLetter') );
Aber dann stellt sich heraus, dass wir für jede Aufnahme unsere eigene Suite erstellen müssen, weil Die URL kann für die gesamte Suite festgelegt werden, jedoch nicht für einen einzelnen Snapshot innerhalb der Suite. Es versteht sich, dass jede Suite in einer separaten Browsersitzung ausgeführt wird. Dies ist im Prinzip richtig - die Tests sollten nicht voneinander abhängen, aber das Öffnen einer separaten Browsersitzung und das anschließende Laden des Storybooks nimmt viel Zeit in Anspruch, viel mehr als nur das Durchlaufen von Storys im Rahmen des bereits geöffneten Storybooks. Daher ist bei einer großen Anzahl von Suiten die Testausführungszeit sehr langsam. Ein Teil des Problems kann durch Parallelisierung der Testausführung gelöst werden, die Parallelisierung verbraucht jedoch viele Ressourcen (Speicher, Prozessor). Nachdem wir uns entschlossen hatten, Ressourcen zu sparen und gleichzeitig während des Testlaufs nicht zu viel zu verlieren, lehnten wir es ab, den Frame in einem separaten Browserfenster zu öffnen. Tests werden in einer einzelnen Browsersitzung durchgeführt, aber vor jeder Aufnahme wird die nächste Story in den Frame geladen, als hätten wir gerade das Storybook geöffnet und auf einzelne Knoten im Storys-Baum geklickt. Bildbereich - gesamter Rahmen:
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); }) );
Leider haben wir bei dieser Option neben der Möglichkeit, den Bildbereich auszuwählen, auch die Möglichkeit verloren, Standardaktionen der Gemini-Engine für die Arbeit mit Elementen des DOM-Baums (mouseDown (), mouseMove (), focus () usw.) usw. zu verwenden. zu. Elemente innerhalb des Gemini-Rahmens sehen nicht. Wir haben aber weiterhin die Möglichkeit, die Funktion executeJS () zu verwenden, mit der Sie JavaScript-Code in einem Browserkontext ausführen können. Basierend auf dieser Funktion haben wir die Analoga der von uns benötigten Standardaktionen implementiert, die bereits im Kontext des Storybook-Frames funktionieren. Hier mussten wir ein wenig „zaubern“, um Parameterwerte aus dem Testkontext in den Browserkontext zu übertragen - executeJS () bietet leider keine solche Möglichkeit. Daher sieht der Code auf den ersten Blick etwas seltsam aus - die Funktion wird in eine Zeichenfolge übersetzt, ein Teil des Codes wird durch Parameterwerte ersetzt und in ExecuteJs () wird die Funktion mit eval () aus der Zeichenfolge wiederhergestellt:
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 };
Wiederholungen der Ausführung
Nachdem die visuellen Tests geschrieben wurden und zu arbeiten begannen, stellte sich heraus, dass einige der Tests nicht sehr stabil waren. Irgendwo hat das Symbol keine Zeit zum Zeichnen, irgendwo wird die Auswahl nicht entfernt und wir erhalten eine Nichtübereinstimmung mit dem Referenzbild. Daher wurde beschlossen, Wiederholungstests der Testausführung aufzunehmen. In Gemini funktionieren Wiederholungsversuche jedoch für die gesamte Suite. Wie oben erwähnt, haben wir versucht, Situationen zu vermeiden, in denen für jede Aufnahme eine Suite erstellt wird. Dies verlangsamt die Ausführung von Tests zu sehr. Auf der anderen Seite ist die Wahrscheinlichkeit, dass die wiederholte Ausführung der Suite ebenso wie die vorherige abfällt, umso größer, je mehr Aufnahmen im Rahmen einer Suite gemacht werden. Daher war es notwendig, Wiederholungsversuche durchzuführen. In unserem Schema wird die Ausführung nicht für die gesamte Suite wiederholt, sondern nur für die Bilder, die den vorherigen fehlgeschlagenen Lauf nicht weitergegeben haben. Zu diesem Zweck analysieren wir im TEST_RESULT-Ereignishandler das Ergebnis des Vergleichs des Snapshots mit dem Standard. Für die Snapshots, die den Vergleich nicht bestanden haben, erstellen wir nur für sie eine neue 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}`)); });
Das TEST_RESULT-Ereignis war übrigens auch nützlich, um den Fortschritt der bestandenen Tests zu visualisieren. Jetzt muss der Entwickler nicht mehr warten, bis alle Tests abgeschlossen sind. Er kann die Ausführung unterbrechen, wenn er feststellt, dass ein Fehler aufgetreten ist. Wenn die Testausführung unterbrochen wird, schließt Gemini die vom Selenserver geöffneten Browsersitzungen korrekt.
Wenn die neue Suite nach Abschluss des Testlaufs nicht leer ist, führen Sie sie aus, bis die maximale Anzahl von Wiederholungen erschöpft ist:
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));
Zusammenfassung
Heute haben wir ungefähr fünfzig visuelle Tests, die die wichtigsten visuellen Zustände unserer Anwendung abdecken. Natürlich besteht keine Notwendigkeit, über die vollständige Abdeckung von UI-Tests zu sprechen, aber wir haben uns ein solches Ziel noch nicht gesetzt. Tests funktionieren sowohl auf den Workstations der Entwickler als auch auf den Build-Agenten erfolgreich. Während Tests nur im Kontext von Chrome und Internet Explorer durchgeführt werden, ist es in Zukunft möglich, andere Browser zu verbinden. All diese Wirtschaftlichkeit dient dem Selemium-Grid mit zwei Knoten, die auf virtuellen Maschinen bereitgestellt werden.
Von Zeit zu Zeit sehen wir uns mit der Tatsache konfrontiert, dass nach der Veröffentlichung der neuen Version von Chrome die Referenzbilder aktualisiert werden müssen, da einige Elemente etwas anders angezeigt wurden (z. B. Scroller), aber es gibt nichts zu tun. Es ist selten, aber es kommt vor, dass Sie beim Ändern der Struktur eines Redux-Speichers die gespeicherten Zustände für die Tests erneut abrufen müssen. Es ist natürlich nicht einfach, genau den Zustand wiederherzustellen, der sich zum Zeitpunkt seiner Erstellung im Test befand. In der Regel merkt sich noch niemand, in welcher Datenbank diese Bilder aufgenommen wurden und Sie müssen mit anderen Daten ein neues Bild aufnehmen. Dies ist ein Problem, aber kein großes. Um dies zu lösen, können Sie Bilder auf einer Demobasis aufnehmen, da wir Skripte für die Erstellung haben und auf dem neuesten Stand gehalten werden.