Visual Regression Testing mit Puppeteer und Resemble.js (Teil 2)

In meinem letzten Artikel habe ich mit einem Beispiel beschrieben, wie man mittels Puppeteer automatisert Screenshots von Websites erstellt. Das Problem stellte sich für uns, weil wir im großen Umfang CMS-Unterseiten auf Frontend-Probleme nach einem Upgrade testen wollten.

Ziel war es, ein Skript zu erstellen, welches vor einem Upgrade gestartet werden kann und zunächst den Status Quo von Websites in Screenshots festhält.
Nachdem die Upgrades an den Websites durchgeführt wurden, kann das Skript erneut gestartet werden und es werden automatisch visuelle Vergleichtests durchgeführt.

Test-Logik

Zum Ablauf der Visual Regression Tests habe ich folgende kleine Test-Logik entwickelt:

Flowchart

Wenn ich keinen Screenshot finde, erstelle ich einen für einen späteren Vergleich. Finde ich einen vor, dann erstelle ich einen neuen und vergleiche ihn direkt.

Visual Regression Testing mit Puppeteer und Resemble.js

Um den obigen Test-Algorithmus abzubilden, muss die app.js aus meinem letzten Artikel um eine Testschleife, die zusätzliche Library Resemble.js und das File System-Modul von Node.js erweitert werden.

Die folgende Vorgehenswiese lässt sich auch anhand meiner Commits im Github-Repository nachverfolgen.

Dateizugriff einrichten

Für den Zugriff auf das Dateisystem stellt Node.js das fs-Modul bereit. Damit lassen sich klassische Dateioperationen durchführen (copy, move etc.).

Um das Modul zu verwenden, muss es mittels require in der app.js eingebunden werden:

const fs = require('fs')

Bisher war der Pfad- und Dateiname der Screenshots, die in der takeScreenshot()-Funktion erstellt werden noch hard coded. Weil die Funktion zukünftig sowohl Vorher- als auch Nachher-Screenshots festhalten soll, werden folgende Änderungen vorgenommen:

const screenshotsFolder = './screenshots/'

und

await page.screenshot({ path: filename, fullPage: true })

Test-Logik aufbauen

Jetzt kann die eigentliche Test-Logik aufgebaut werden. Ziel ist es, die Screenshots noch nicht zu vergleichen, aber schon die nötige Schleife zusammenzubasteln. Ich habe dafür eine Funktion erstellt, welche den Test startet. Hierfür eignet sich in diesem Fall die Verwendung der Immediately-invoked Function Expression.

Die Immediately-invoked Function Expression ist eine Möglichkeit, Funktionen sofort auszuführen, sobald sie erstellt werden:

(() => {
  /* Befehle */
})()

Unsere asynchrone Funktion sieht dann so aus:

// Immediately-invoked arrow function after launch
(async () => { 
    // Create screenshots folder if it does not exist
    if (!fs.existsSync(screenshotsFolder)) {
        fs.mkdir(screenshotsFolder, (err) => {
            if (err) throw err
        })
    }

    for (const website of websites) {
        const orgScreenshotPath = screenshotsFolder + website.filename + '.png'
        const testScreenshotPath = screenshotsFolder + website.filename + '_test.png'
        // Check if both original and testing screenshot already exist
        if (fs.existsSync(orgScreenshotPath) && fs.existsSync(testScreenshotPath)) {
            // Both exist run regressionTest()
        } else {
            if (fs.existsSync(orgScreenshotPath)) {
                // Original exists create test screenshot
                await takeScreenshot(website.url, testScreenshotPath)
                    .then(console.log('Created test: ' + website.filename))
                // run regressionTest()
            } else {
                // No Original exists, let's create a new one
                await takeScreenshot(website.url, orgScreenshotPath)
                    .then(console.log('Created original: ' + website.filename))
            }
        }
    }
})()

Mit fs.existsSync() wird geprüft, ob eine Datei unter dem angegeben Pfad existiert. Dies könnte auch mittels Promises/await asynchron und ohne Callbacks gemacht werden (Momentan noch experimental).

Vergleichen von Screenshots mit Resemble.js

Jetzt fehlt nur noch die regressionTest()-Funktion, damit wir unsere Tests durchführen können.

Hierfür muss zunächst Resemble.js mittels npm installiert und eingebunden werden:

$ npm install resemblejs --save

In unserer app.js:

const resemble = require('resemblejs')

Die asynchrone regressionTest()-Funktion sieht wie folgt aus:

const regressionTest = async (filename, orgScreenshotPath, testScreenshotPath) => {
    console.log('Visual Regression: ' +  filename)

    const diffFolder = screenshotsFolder + 'diff/'

    resemble(orgScreenshotPath).compareTo(testScreenshotPath).onComplete(data => {
        if (data.misMatchPercentage > 0) {
            console.log('Missmatch of ' + data.misMatchPercentage + '%')

            // Create screenshots/diff folder only when needed
            if (!fs.existsSync(diffFolder)) {
                fs.mkdir(diffFolder, (err) => {
                    if (err) throw err
                })
            }

            // Set filename and folder for Diff file
            const diffScreenshotPath = diffFolder + filename + '_' + data.misMatchPercentage + '_diff.png'
            fs.writeFile(diffScreenshotPath, data.getBuffer(), (err) => {
                if (err) throw err
            })
        }
    })
}

Die Funktion erhält die Parameter filename aus dem websites-Array, sowie den zusammengesetzten Pfad zum Original. Dazu kommt ein Vergleichsscreenshot (orgScreenshotPath, testScreenshotPath).

Diese werden nun durch resemble verglichen:

resemble(orgScreenshotPath).compareTo(testScreenshotPath)

Wenn ein Unterschied zwischen orgScreenshotPath und testScreenshotPath besteht wird eine Differenzgrafik erstellt. Diese zeigt standardmäßig die Unterschiede in Magenta an. Damit Fehler schneller gefunden werden können, werden diese Differenzbilder im Unterverzeichnisscreenshots/diff abgelegt.

In folgenden Screenshots fehlen Seiteninhalte. Resemble.js findet den Unterschied und stellt ihn gut sichtbar dar:

Resemble.js Animation

Schneller Vergleichen von einzelnen Screenshots

Wenn eine einzelne URL verglichen werden soll, ist es etwas mühselig diese immer in das websites-Array in app.js einzufügen. Deshalb habe ich in dem Skript die Möglichkeit ergänzt, beim Aufruf URL(s) als Kommandozeilenargumente anzuhängen:

$ node app.js https://rrzk.uni-koeln.de/

let websites = []

process.argv = process.argv.slice(2) // Slice away the first two command line arguments

if (process.argv.length == 0) { 
    // If no command line arguments are given add hardcoded examples
    websites = [
        { url: 'https://rrzk.uni-koeln.de/', filename: 'homepage' },
        { url: 'https://rrzk.uni-koeln.de/aktuelles.html', filename: 'news' },
        { url: 'https://typo3.uni-koeln.de/typo3-angebote.html', filename: 'typo3-offerings' },
        { url: 'https://typo3.uni-koeln.de/typo3-links-und-downloads.html', filename: 'typo3-links-and-downloads' }
    ]
} else {
    process.argv.forEach((val, index) => {
        try { // Check if argument is a URL
            let screenshotURL = new URL(val)
            // Add URL to websites array if valid and create filename
            websites.push({ url: screenshotURL.href, filename: index + '_' + screenshotURL.host})
        } catch (err) {
            console.error('"' + val + '" Is not a valid URL!')
        }
    })
}

Mittels der Klasse URL, wird die Zeichenkette in ein URL-Objekt konvertiert. Wenn dies fehlschlägt, enthält die Zeichenkette keine gültige URL.

Das finale app.js-Skript:

Das finale app.js Skript ist nun fertig app.js Download

Zum Starten einfach app.js ausführen: $ node app.js.

Wenn Screenshots von einzelnen Websites verglichen werden sollen, kann dies folgendermaßen gemacht werden:

$ node app.js https://rrzk.uni-koeln.de/
Created test: 0_rrzk.uni-koeln.de
Visual Regression: 0_rrzk.uni-koeln.de
Missmatch of 58.22%

Ausführen mittels Docker

Dieses Skript kann auch in einer containerbasierten Umgebung ausgeführt werden. Als Basis-Image verwende ich zenato/puppeteer. Das Image enthält einen standardisierten „Chrome“-Browser und stellt eine Umgebung bereit, in der Screenshots in konsistenter Weise erstellt werden.

Mein Dockerfile dafür sieht so aus:

FROM zenato/puppeteer:latest
USER root
COPY package.json /app/
COPY app.js /app/
RUN cd /app && npm install --quiet
WORKDIR /app
ENTRYPOINT [ "node" , "app.js" ]

Image erstellen:

$ docker build -t movd/puppeteer-resemble-testing:latest .

Container ausführen:

$ docker run --rm -v "${PWD}/screenshots:/app/screenshots" movd/puppeteer-resemble-testing:latest http://example.com

Das Image kann auch direkt vorgebaut von „Docker Hub“ geladen werden:

$ docker pull movd/puppeteer-resemble-testing:latest

Die hier erstellte Lösung basiert auf den Anforderungen im RRZK. Ich freue mich über Feedback und weitere Use-Cases, weil die Test-Logik eine einfache Schleife ist, ließe sich diese auch für andere Testreihenfolgen anpassen. Resemble.js ließe sich auch besonders gut in automatisierte Test mit Mocha oder Jest verwenden.

Visuelle Tests von Websites nach Updates und Änderungen

An der Uni Köln wird seit mehreren Jahren auf das Content-Managment-System (CMS) TYPO3 gesetzt. Immer mehr Institute und universitäre Einrichtungen greifen dafür auf die TYPO3-Angebote des RRZK zurück, sodass aktuell über 50.000 Seiten, auf über 960 Domains bereitgestellt werden. CMS und Webapplikationen erfordern regelmäßige Wartung und Bugfixes. Jedoch birgt jede Änderung oder Korrektur die Gefahr, dass Nebenwirkungen auftreten und Fehler vielleicht an einer unerwarteten Stelle im System verursacht werden. Regressionstests haben die Aufgabe dies vorzubeugen und Fehler zu finden. Das Ziel lautet: Das, was vorher funktioniert hat, soll auch nach einem Upgrade funktionieren.

Als im vergangenen Sommer die Upgrades unserer Systeme auf die Long Term Support Version v8 des CMS anstanden, haben wir deshalb nach einer Lösung gesucht. Ziel war es ein Programm zu finden, mit der die Front-Ends der Websites auf mögliche “Macken” nach den Upgrades getestet werden können.

Im Web-Bereich wurden in den letzten Jahren einige Libraries und Tools für visuelle Regressionstest entwickelt. Ein visueller Regressionstest führt Front-End- oder User-Interfacetests durch, indem es die Screenshots des User-Interface erfasst und mit den Originalbildern vergleicht. Wenn ein neuer Screenshot vom Referenzscreenshot abweicht, warnt das visuelle Regressionstool.

Visual Regression Testing mit Puppeteer und Resemble.js

“Puppeteer Logo” erstellt von Google, geteilt gemäß der CC BY 3.0-Lizenz

Seit 2017 stellt das EntwicklerInnen-Team des Chrome-Browsers mit Puppeteer eine Open-Source Node.js Library bereit, mit welcher ein Chrome Browser ohne eine grafische Oberfläche über eine API angesteuert und automatisiert werden kann. Mittels Puppeteer lassen sich z.B. Screenshots von Websites erstellen aber auch Interaktionen mit dem Front-End einer Website simulieren.

Die mit Puppeteer erstellten Screenshots, gilt es zu vergleichen. Hierfür bietet sich die gut gepflegte Library Resemble.js an. Diese ist darauf spezialisiert Bilddateien abzugleichen und Unterschiede auszugeben.

Teil 1: Screenshots erzeugen

Mit folgenden Tutorial will ich zeigen wie man mit Puppeteer Screenshots mehrerer Seiten erstellt. Ziel ist es am Ende einen Ordner mit Screenshots zu haben, welche anschließend als Ausgangsbilder für Tests dienen werden.

In einem weiteren Artikel werde ich darauf eingehen, wie diese Screenshots mit einem späteren Zustand verglichen werden können.

Der Code zu dieser Anleitung ist auch auf GitHub zu finden. Link zum Stand dieses Artikels

Node.js und Puppeteer installieren

Vorraussetzung: Zum Ausführen wird die JavaScript Runtime node samt der mitgelieferten Packetverwaltung npm benötigt.

Installationsanleitung für verschiedene Betriebssysteme gibt es auf nodejs.org. Für die Verwendung in Linux-Distributionen bietet NodeSource Repositories mit kompilierten Binaries an.

Nachdem nun Node.js installiert ist, muss noch ein Ordner erstellt und in das Verzeichnis gewechselt werden:

mkdir puppeteer-resemble-testing
cd puppeteer-resemble-testing

Als nächstes muss ein neues Projekt erstellt werden:

npm init

Für den Zweck dieser Anleitung, kann jede Frage mittels der Return-Taste bestätigt werden. Dabei wird eine neue package.json erstellt.

Nun muss Puppeteer installiert werden:

npm install puppeteer --save

Die Installation kann eine kurze Weile dauern. Denn es wird auch automatisch eine Version von Chromium heruntergeladen und innerhalb des Projekts abgespeichert.

Einzelnen Screenshot erstellen

Puppeteer steht jetzt bereit und kann verwendet werden. Unser erstes Skript erzeugt einen Screenshot der kompletten RRZK-Startseite. Das Ergebnis wird als screenshot.png abgelegt.

Inhalt von app.js:

const puppeteer = require('puppeteer')

const takeScreenshot = async () => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  await page.setViewport({ width: 1920, height: 1080 })
  await page.goto('https://rrzk.uni-koeln.de/')
  await page.screenshot({ path: './screenshot.png', fullPage: true })
  await page.close()
  await browser.close()
}

takeScreenshot()

Puppeteer hat eine umfassende API-Dokumentation, alle obigen Parameter sind dort ausführlich beschrieben.

Puppeteer ist eine Promise-basierte Bibliothek, in diesem Fall bedeutet dies, dass die Aufrufe an die Chrome-Instanz asynchron durchgeführt werden. Damit der Code des Skript einfach zu lesen ist, wird async/await verwendet. Unsere takeScreenshot() Pfeilfunktion muss deshalb als async definiert werden.

Das Skript wird ausgeführt mit dem Befehl:

node app.js

Im folgenden werden wir auf dieses Skript aufbauen und nach und nach mehr Features hinzufügen.

Mehrere Screenshots in Stapelverarbeitung

Ziel ist es, weiterhin Screenshots vieler Seiten zu erstellen. Um etwas Ordnung zu waren, erstellen wir deshalb zunächst das Unterverzeichnis “screenshots”:

mkdir screenshots

Danach legen wir in app.js ein Array aus Objekten an. In einem Objekt wird jeweils die URL und der Screenshot-Dateiname hinterlegt.

const websites = [
  { url: 'https://rrzk.uni-koeln.de/', filename: 'homepage' },
  { url: 'https://rrzk.uni-koeln.de/aktuelles.html', filename: 'news' },
  { url: 'https://typo3.uni-koeln.de/typo3-angebote.html', filename: 'typo3-offerings'},
  { url: 'https://typo3.uni-koeln.de/typo3-links-und-downloads.html', filename: 'typo3-links-and-downloads'}
]

Zum Test kann dieses Array nun durchlaufen werden:

for (const website of websites) {
  console.log(website.url)
  console.log(website.filename)
}

Beim erneuten Ausführen des Skripts werden nun die Inhalte des websites-Arrays ausgegeben.

Bisher ist in unserer takeScreenshot() Funktion die URL und der Dateiname hartkodiert. Die Funktion muss jetzt mit url und filename gefüttert werden. Daraus ergeben sich folgende Änderungen:

const takeScreenshot = async (url, filename) => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  await page.setViewport({ width: 1920, height: 1080 })
  await page.goto(url)
  await page.screenshot({ path: './screenshots/' + filename + '.png', fullPage: true })
    .then(console.log('Screenshot: ' + filename))
  await page.close()
  await browser.close()
}

Fast geschafft, nun noch beim durchlaufen des Arrays die takeScreenshot()-Funktion aufrufen und die Werte übergeben:

for (const website of websites) {
  // console.log(website.url)
  // console.log(website.filename)
  takeScreenshot(website.url, website.filename)
}

Unsere finale kompakte app.js sieht dann wie folgt aus:

const puppeteer = require('puppeteer')

const websites = [
  { url: 'https://rrzk.uni-koeln.de/', filename: 'homepage' },
  { url: 'https://rrzk.uni-koeln.de/aktuelles.html', filename: 'news' },
  { url: 'https://typo3.uni-koeln.de/typo3-angebote.html', filename: 'typo3-offerings'},
  { url: 'https://typo3.uni-koeln.de/typo3-links-und-downloads.html', filename: 'typo3-links-and-downlods'}
]

const takeScreenshot = async (url, filename) => {
  const browser = await puppeteer.launch()
  const page = await browser.newPage()

  await page.setViewport({ width: 1920, height: 1080 })
  await page.goto(url)
  await page.screenshot({ path: './screenshots/' + filename + '.png', fullPage: true })
    .then(console.log('Screenshot: ' + filename))
  await page.close()
  await browser.close()
}

for (const website of websites) {
  // console.log(website.url)
  // console.log(website.filename)
  takeScreenshot(website.url, website.filename)
}

Nach dem Ausführen liegen nun unter „/screenshots“ vier PNG-Dateien.

Durch den .then Handler wird nach einlösen des Promise screenshot kurz Rückmeldung gegeben. Hiermit zeigt sich auch schön die asynchrone Arbeitsweise von Puppeteer.

In meinem nächsten Artikel werde ich das Skript um einen Vorher-Nachher-Vergleich mit Resemble.js erweitern, sowie das ganze mittels Docker so abpacken, dass man es einfach auf einem Linux-Server ausführen kann.