Auf deinem Laptop laufen die visuellen Regressionstests sauber durch. Du pushst, die Pipeline startet, und dieselben Tests lassen den Browser entweder stillschweigend abstürzen oder melden fünfzig „Änderungen“, die in Wirklichkeit nur Anti-Aliasing-Unterschiede sind. An deinem Code hat sich nichts geändert, die CI sieht das anders.
Die Lücke zwischen „läuft lokal“ und „läuft in der CI“ ist fast immer ein Infrastrukturproblem, kein Testproblem. Damit selbst gehostete visuelle Regressionstests in einer CI-Pipeline zuverlässig laufen, müssen vier Teile richtig zusammenspielen: ein CI-Runner mit Docker-Executor, ein Container, in dem dem headless Chromium nicht der Shared Memory ausgeht, ein Diff-Werkzeug, das CI-lesbare Reports ausgibt, und ein VPS, das für den Speicherbedarf dimensioniert ist, den Headless-Browser tatsächlich haben.
Diese Anleitung baut genau diesen Stack von Anfang bis Ende auf. Am Ende hast du eine funktionierende .gitlab-ci.yml (oder ein Gitea-Äquivalent), eine Docker-Umgebung, in der Chromium nicht mehr abstürzt, BackstopJS, das JUnit-Reports für deine Pipeline erzeugt, und ein passend dimensioniertes VPS, auf dem das Ganze läuft.
Kurzfassung
- Führe den Browser lokal und in der CI im selben Docker-Image aus. Die meisten falsch-positiven Diffs entstehen durch Schrift- und Rendering-Unterschiede zwischen den Umgebungen. Pinne das Image, und sie verschwinden.
- Behebe den 64MB-Standardwert von /dev/shm. Die Standardgröße für Shared Memory in Docker lässt Chromiums Renderer verhungern, der dann in der CI lautlos abstürzt. Setze shm_size: '2gb' in der docker-compose.yml oder übergib --disable-dev-shm-usage an Chromium.
- Nimm BackstopJS als primäres, selbst hostbares Werkzeug. Es steht unter MIT-Lizenz (v6.3.25), ist über das Flag --docker Docker-first und gibt JUnit-Reports aus. Wenn du ohnehin Playwright einsetzt, ist dessen eingebautes toHaveScreenshot() ein guter Einstieg ganz ohne zusätzliche Installation.
- Plane 4GB RAM als Basis ein. Kalkuliere den Speicher großzügig. In der Praxis verbraucht ein Chromium-Screenshot-Job beim Erfassen ganzer Seiten deutlich mehr Speicher, als er im Leerlauf zu brauchen scheint. 4GB RAM sind daher die sicherere Basis für ein bis zwei sequenzielle oder leicht parallelisierte Jobs.
Was dieser Leitfaden nicht abdeckt
Dies ist eine Aufbauanleitung für Infrastruktur, für die du dich bereits entschieden hast. Ein paar Dinge bleiben bewusst außen vor:
- Es ist kein vollständiger Werkzeugvergleich. Diese Anleitung konzentriert sich auf den Stack, den du in einer selbst gehosteten CI-Umgebung tatsächlich betreiben kannst: BackstopJS, Docker und deinen eigenen Runner.
- Sie behandelt kein Self-Hosting von Argos. Argos ist Open Source, aber der öffentliche Produktablauf und die Dokumentation drehen sich um die gehostete Argos-App und CI-Integrationen statt um einen einfachen Self-Hosting-Weg für den Produktivbetrieb. Für das Ziel dieser Anleitung ist BackstopJS das sicherere Praxisbeispiel.
- Sie empfiehlt Lost Pixel nicht. Das Projekt wurde im April 2026 archiviert. Nutze für neue Deployments BackstopJS.
- Sie führt die Debatte Self-Hosting gegen Managed SaaS nicht erneut. Wenn du hier bist, hast du Percy oder Chromatic bereits ausgeschlossen.
Voraussetzungen
Ein paar Dinge sollten vor dem ersten Befehl stehen:
- Ein selbst gehosteter CI-Runner oder ein Plan, einen aufzusetzen (weiter unten beschrieben).
- Docker auf dem Runner-Host installiert.
- Ein Node.js-Projekt mit einem vorhandenen Testziel (eine laufende App-URL oder eine Reihe von Komponenten-Routen zum Erfassen).
- Shell-Zugriff auf das VPS oder den Host, auf dem der Runner läuft.
Warum visuelle Tests lokal bestehen und in der CI scheitern
Der Browser, der deine Baseline unter macOS rendert, ist nicht der Browser, der den Vergleich in einem Linux-CI-Container rendert. Playwrights eigene Dokumentation wird da sehr deutlich: Das Browser-Rendering kann je nach Host-OS, Version, Einstellungen, Hardware, Stromversorgung, Headless-Modus und weiteren Faktoren variieren. Die Empfehlung ist ebenso direkt: Führe Tests für konsistente Screenshots in derselben Umgebung aus, in der die Baseline-Screenshots erzeugt wurden.
Diese eine Empfehlung ist der Grund, warum es den Rest dieses Setups überhaupt gibt. Drei Abweichungen erklären nahezu jeden Fehlalarm:
- Unterschiede bei Schriften und Anti-Aliasing. Deine Entwicklungsmaschine und der CI-Container bringen unterschiedliche Schriftpakete und Subpixel-Rendering mit. Textlastige Seiten liefern bei jedem Lauf Diffs, auch wenn sich nichts geändert hat.
- Headless- gegen Headed-Rendering. Ein Browser mit Oberfläche und ein Headless-Browser können dieselbe Seite leicht unterschiedlich umbrechen.
- Erschöpfter Shared Memory. Das ist der lautlose Fall. Dockers /dev/shm ist standardmäßig 64MB groß, und headless Chromium nutzt Shared Memory für seine Renderer-Prozesse. Geht der aus, stürzt Chromium ohne brauchbare Fehlermeldung ab. Dein Job stirbt einfach oder hängt.
Die ersten beiden löst du, indem du für Baseline-Erzeugung und Vergleich ein einziges Docker-Image pinnst. Den dritten löst der Abschnitt zur Docker-Konfiguration weiter unten. Behebe alle drei, und aus Tests, die „in der CI flaky“ waren, werden deterministische Tests.

Werkzeugwahl: BackstopJS oder Playwrights eingebautes Diffing
Wenn du Playwright bereits für End-to-End-Tests einsetzt, ist die eingebaute Assertion toHaveScreenshot() der schnellste Weg: keine zusätzlichen Abhängigkeiten, pixelgenaues Diffing und Baselines mit Plattform-Suffix von Haus aus. Für eine kleine Suite ist das ein legitimes Ziel, und für Teams mit rund fünfzig Screens ohne gemeinsame Komponentenbibliothek reicht es womöglich dauerhaft.
An seine Grenzen stößt es beim Review-Workflow. Playwrights Diffing arbeitet rein pixelbasiert und hat keine PR-Review-UI. Einen berechtigten Schwung visueller Änderungen freizugeben heißt also, Snapshots von Hand neu zu erzeugen. Sobald die Zahl deiner Baselines wächst, wird diese manuelle Freigabeschleife mühsam.
BackstopJS ist das spezialisierte Werkzeug, das genau für diese Schleife gebaut wurde. Es steht unter MIT-Lizenz (aktuell v6.3.25), ist über das Flag --docker Docker-first und gibt JUnit-XML aus, das ein CI-Runner nativ liest. Sein Baseline-Workflow (Referenz erzeugen, dagegen testen, akzeptierte Diffs als neue Baseline freigeben) ist genau das Betriebsmodell, bei dem die meisten selbst gehosteten VRT-Setups am Ende landen. Für den Rest dieser Anleitung ist BackstopJS das Praxisbeispiel.
Zwei Werkzeuge, die anderswo empfohlen werden, fallen für diese Anleitung weg: Argos kommt hier nicht vor, weil sein öffentlicher Produktablauf auf die gehostete App und CI-Integrationen ausgerichtet ist, und Lost Pixel ist archiviert. Für ein praxistaugliches Self-Hosting-Setup ist BackstopJS das sicherere Praxisbeispiel.
| Achse | BackstopJS | Playwright toHaveScreenshot() |
|---|---|---|
| PR-Review-Workflow | Eingebauter Report und approve-Befehl | Snapshots manuell neu erzeugen |
| Baseline-Verwaltung | Befehle reference / test / approve | Snapshot-Dateien pro Test |
| Docker-normalisiertes Rendering | Flag --docker | Eigenes Image mitbringen |
| Installationsaufwand | Separate Abhängigkeit | Bereits vorhanden, wenn du Playwright nutzt |
Für einen ausführlicheren Vergleich über mehr Werkzeuge und Kriterien hinweg deckt unser Vergleich von BackstopJS, Argos und Lost Pixel die gesamte Auswahlentscheidung ab.
Einen selbst gehosteten CI-Runner auf einem VPS bereitstellen
Das Teil, das tatsächlich für konsistentes Rendering sorgt, ist der Docker-Executor: Der Runner führt jeden Job in einem Container aus, den du definierst, wodurch die Browser-Umgebung jedes Mal identisch ist. Wähle die Runner-Plattform danach aus, was sie sonst noch alles leisten soll.
Selbst gehostetes GitLab CE ist die schwergewichtigere Option: Repositories, CI/CD, Registry, Issues und Merge Requests in einer Instanz. Wichtig für diese Anleitung ist der Docker-Executor des GitLab Runners, mit dem jeder Visual-Regression-Job jedes Mal im selben gepinnten Container-Image läuft. GitLab, Gitea, Jenkins, Forgejo, Portainer und Docker sind alle als Ein-Klick-Deployments verfügbar im dem Cloudzy-Marketplace, was die Einrichtung erheblich verkürzt.
Gitea mit act-runner ist die leichtgewichtige Alternative. Gitea ist ein in Go geschriebener Git-Dienst mit einer zu GitHub Actions kompatiblen Workflow-Engine (über act-runner). Wenn dein Team mit der Actions-Syntax vertraut ist, ist das ein kleiner, schnell aufgesetzter Runner.
Egal wofür du dich entscheidest, das Konfigurationsziel bleibt dasselbe: ein Runner, der Jobs annimmt und sie mit einem Docker-Executor ausführt. Alles Weitere (Container-Konfiguration, BackstopJS, die Beispiel-Pipeline) setzt diesen Executor voraus.
Docker so konfigurieren, dass Chromium nicht abstürzt
Das ist der Fehler, der die meiste Zeit kostet. Docker mountet /dev/shm standardmäßig mit 64MB. Headless Chromium nutzt diese Shared-Memory-Partition für seine Renderer-Prozesse und braucht bei einem Screenshot der ganzen Seite deutlich mehr als 64MB. Läuft sie voll, stirbt der Renderer, oft ohne jede Fehlermeldung, die auf Shared Memory hinweist. Der Job hängt, läuft in einen Timeout oder meldet einen generischen Browser-Absturz.
Es gibt zwei Lösungen, und beide funktionieren.
Lösung A: die Shared-Memory-Größe in Compose erhöhen. Der Schlüssel shm_size legt die Größe der /dev/shm-Partition des Containers fest. Die Docker Compose-Dateireferenz bestätigt, dass shm_size die Größe des Shared Memory konfiguriert, den der Service-Container nutzen darf. Setze ihn im Service-Block:
# docker-compose.yml
services:
vrt:
image: backstopjs/backstopjs:6.3.25
shm_size: '2gb' # override the 64MB default
volumes:
- ./:/src
working_dir: /src
Lösung B: Chromium anweisen, /dev/shm gar nicht erst zu nutzen. Das Flag --disable-dev-shm-usage bringt Chromium dazu, Shared-Memory-Dateien nach /tmp statt in die Shared-Memory-Partition zu schreiben. Übergib es in den Startargumenten des Browsers. In einer backstop.json-Konfiguration sind das die engineOptions, in Playwright die launchOptions:
// backstop.json (fragment)
{
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Lösung A ist sauberer, wenn du die Compose-Datei kontrollierst. Lösung B ist die portable Wahl, wenn du nur an die Start-Flags des Browsers herankommst. Beides gleichzeitig anzuwenden schadet nicht.
Profi-Tipp: Nutze lokal und in der CI exakt dasselbe Docker-Image. BackstopJS liefert dir das gratis mit dem Flag --docker, das die Aufnahme in einem gepinnten Referenz-Image ausführt, sodass deine Maschine und der Runner identisch rendern. Genau das beseitigt die Fehlalarme durch Schriften und Anti-Aliasing: Image einmal pinnen und Schluss mit der Jagd nach Phantom-Diffs.

BackstopJS für die CI einrichten
BackstopJS erzeugt einen JUnit-XML-Report, den dein CI-Runner liest, um den Build bestehen oder scheitern zu lassen. Genau darum verdrahtet man es in einer Pipeline, statt es von Hand auszuführen. Installiere es und initialisiere eine Konfiguration:
npm install --save-dev backstopjs
npx backstop init
Richte anschließend backstop.json auf die Seiten aus, die du erfassen willst, und aktiviere den CI-Report:
// backstop.json (fragment)
{
"id": "my_app_vrt",
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 }
],
"scenarios": [
{
"label": "Homepage",
"url": "http://app:3000/",
"selectors": ["document"]
}
],
"report": ["CI"],
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Die CI-Report-Einstellung ist es, die JUnit-XML ausgibt. Nutze das Flag --docker, wenn du diese Befehle lokal auf deinem Rechner ausführst. In einem CI-Job, der bereits das Image backstopjs/backstopjs verwendet, rufst du stattdessen direkt backstop test auf. Der Baseline-Workflow besteht aus drei Befehlen:
npx backstop reference --docker # capture the approved baseline
npx backstop test --docker # run the comparison, emit the report
npx backstop approve # accept the current diffs as the new baseline
Ein Vorbehalt zur Baseline-Verwaltung: Jede berechtigte UI-Änderung bedeutet, approve auszuführen, um die neuen Screenshots abzusegnen. Ab etwa hundert Szenarien ist dieser Freigabeschritt echter Betriebsaufwand: Jemand muss sich die Diffs ansehen und entscheiden, welche beabsichtigt sind, und der Aufwand wächst mit dem Team, das damit arbeitet. Dieser Pflegeaufwand ist der tatsächliche Preis visueller Regressionstests im großen Maßstab, und du solltest ihn einkalkulieren, bevor du dich auf eine große Suite festlegst.
Eine funktionierende CI-Pipeline-Konfiguration
Die Pipeline läuft in zwei logischen Teilen: die Baseline einmal (oder bei Bedarf) erzeugen und dann bei jeder Änderung dagegen vergleichen. Das folgende Beispiel ist eine GitLab-CI-Konfiguration mit Docker-Executor, bei der der Shared-Memory-Fix bereits über die Start-Flags in backstop.json angewendet ist.
# .gitlab-ci.yml
stages:
- visual-regression
visual_regression:
stage: visual-regression
image:
name: backstopjs/backstopjs:6.3.25
entrypoint: [""]
script:
- backstop test
artifacts:
when: always
reports:
junit: backstop_data/ci_report/xunit.xml
paths:
- backstop_data/bitmaps_test
- backstop_data/html_report
expire_in: 1 week
Die junit-Zeile übergibt den Report an GitLab, sodass Fehler in der Merge-Request-UI auftauchen. Die paths-Zeile bewahrt die Diff-Bitmaps auf, damit du dir ansehen kannst, was sich wirklich geändert hat. Unter Gitea mit act-runner lassen sich dieselben Schritte in einen Workflow im Actions-Stil übertragen:
# .gitea/workflows/visual-regression.yaml
name: Visual Regression
on: [push, pull_request]
jobs:
vrt:
runs-on: ubuntu-latest
container:
image: backstopjs/backstopjs:6.3.25
options: --shm-size=2gb # raise /dev/shm for the job container
steps:
- uses: actions/checkout@v4
- run: backstop test
Beachte die Container-Option --shm-size=2gb im Gitea-Workflow. Das ist derselbe Shared-Memory-Fix wie Lösung A, nur auf Ebene des Job-Containers angewendet, wo es keine separate Compose-Datei zu bearbeiten gibt.
Das VPS für headless Chromium dimensionieren
Der Engpass ist hier RAM, nicht CPU. Die CPU-Last von headless Chromium ist stoßweise (sie springt während einer Aufnahme hoch und fällt dazwischen ab), eine bescheidene Kernzahl kommt also gut mit. Der Speicher ist die Obergrenze, die entscheidet, wie viele Jobs du gleichzeitig fahren kannst. Ein headless Chromium-Prozess liegt im Leerlauf bei etwa 300–500MB und steigt beim Screenshot einer ganzen Seite auf 1–2GB. Ausschlaggebend für die Dimensionierung ist also RAM pro gleichzeitigem Job.
| RAM | Geeignet für |
|---|---|
| 2GB | Knappes Minimum: ein einzelner sequenzieller Job |
| 4GB | Empfohlene Basis: 1–2 gleichzeitige Jobs |
| 8GB | 3–4 gleichzeitige Jobs |
| 16GB | Große Suites und parallele Pipelines |
Diese Zahlen sind Praxiswerte aus echten CI-Läufen, kein Hersteller-Benchmark. Behandle sie als ungefähre Ausgangspunkte und behalte deinen eigenen Speicher-Spitzenwert im Auge.
Kernaussage dieses Abschnitts: 4GB RAM sind die verlässliche Basis. Rechne rund 2GB Reserve pro zusätzlichem gleichzeitigen Visual-Regression-Job dazu.
Der Haken an selbst gehostetem VRT: Ein Runner mit zu wenig Speicher scheitert nicht laut, sondern lässt Chromium lautlos abstürzen, also genau das Symptom, das dieses ganze Setup verhindern soll. Genug Reserve einzuplanen ist die billigste Versicherung, die du kaufen kannst. Wenn du einen Runner-Host nicht von Grund auf selbst bauen willst, bietet der Marktplatz von Cloudzy Ein-Klick-Deployments für selbst gehostetes GitLab und selbst gehostetes Gitea , mit denen du in wenigen Minuten eine laufende CI-Instanz hast, auf einem VPS, das du genau auf die Speicherreserve für headless Chromium zuschneiden kannst, die deine Suite braucht.

Häufig gestellte Fragen
Wie richte ich visuelle Regressionstests in einer CI/CD-Pipeline ein?
Installiere BackstopJS, richte backstop.json auf deine Zielseiten aus und setze die CI-Report-Option so, dass JUnit-XML ausgegeben wird. Nutze backstop reference --docker und backstop test --docker, wenn du von deinem lokalen Rechner aus arbeitest, damit die Screenshots im gepinnten Docker-Image gerendert werden. In der CI führst du den Job im Image backstopjs/backstopjs aus und rufst backstop test direkt auf, danach veröffentlichst du den JUnit-Report aus backstop_data/ci_report/xunit.xml. Verwende einen selbst gehosteten Runner mit Docker-Executor, dimensioniert auf mindestens 4GB RAM für headless Chromium.
Wie viel RAM braucht headless Chromium in einem CI-Container?
Ein headless Chromium-Prozess braucht im Leerlauf etwa 300–500MB und steigt beim Screenshot einer ganzen Seite auf 1–2GB. Für eine zuverlässige CI solltest du rund 4GB RAM für ein bis zwei gleichzeitige Jobs einplanen und je weiterem parallelen Job etwa 2GB ergänzen. Die CPU-Last ist stoßweise, aber nicht der Engpass. RAM entscheidet darüber, wie viele Jobs du gleichzeitig fahren kannst.
Warum bestehen meine visuellen Regressionstests lokal, scheitern aber in der CI?
Das Browser-Rendering unterscheidet sich je nach Umgebung: Schriften, Anti-Aliasing, Headless- gegen Headed-Modus und das OS verändern alle die Ausgabe. Die Lösung ist, Baseline-Erzeugung und Vergleich im selben Docker-Image auszuführen, damit beide identisch rendern. Das Flag --docker von BackstopJS erledigt genau das, indem es die Aufnahmen in einem gepinnten Referenz-Image ausführt.
Wie behebe ich den /dev/shm-Fehler bei Chromium in Docker-CI?
Dockers /dev/shm ist standardmäßig 64MB groß, zu wenig für den Renderer von headless Chromium, was zu lautlosen Abstürzen führt. Erhöhe es entweder mit shm_size: '2gb' in der docker-compose.yml (oder --shm-size=2gb am Job-Container), oder übergib --disable-dev-shm-usage in Chromiums Start-Flags, damit es Shared-Memory-Dateien stattdessen nach /tmp schreibt.
Sollte ich Playwright toHaveScreenshot oder ein spezialisiertes Visual-Regression-Werkzeug nutzen?
Playwrights eingebautes toHaveScreenshot() reicht für kleine Suites: unter rund fünfzig Screens, ohne gemeinsame Komponentenbibliothek und ohne Bedarf an einer PR-Review-UI. Wechsle zu einem spezialisierten Werkzeug wie BackstopJS, sobald du einen echten Baseline-Freigabe-Workflow, einen Review-Report und Docker-normalisiertes Rendering brauchst, was mit wachsender Zahl an Baselines wichtig wird.
Wird Lost Pixel noch gepflegt?
Nein. Lost Pixel wurde im April 2026 archiviert und kommt für neue Deployments nicht infrage. Nutze BackstopJS für ein selbst gehostetes Setup für visuelle Regressionstests.