Zum Hauptinhalt springen
50 % Rabatt alle Pläne, begrenzte Zeit. Ab $2.48/mo
13 min left
Developer Tools und DevOps

Visuelle Regressionstests selbst hosten: Anleitung für die CI-Pipeline

S Von Sajjad 13 Min. Lesezeit
Self-hosted visual regression testing running in a CI pipeline with BackstopJS and Docker

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.

Three reasons visual regression tests pass locally but fail in CI: font and anti-aliasing differences between the dev machine and the CI container, headless versus headed rendering differences, and shared-memory exhaustion from Docker's 64MB /dev/shm default crashing headless Chromium

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.

AchseBackstopJSPlaywright toHaveScreenshot()
PR-Review-WorkflowEingebauter Report und approve-BefehlSnapshots manuell neu erzeugen
Baseline-VerwaltungBefehle reference / test / approveSnapshot-Dateien pro Test
Docker-normalisiertes RenderingFlag --dockerEigenes Image mitbringen
InstallationsaufwandSeparate AbhängigkeitBereits 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.

Two fixes for headless Chromium crashing in Docker CI: raising the container's shared memory with shm_size set to 2gb in docker-compose.yml, or passing the --disable-dev-shm-usage flag so Chromium writes shared-memory files to /tmp instead of the 64MB /dev/shm partition

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.

RAMGeeignet für
2GBKnappes Minimum: ein einzelner sequenzieller Job
4GBEmpfohlene Basis: 1–2 gleichzeitige Jobs
8GB3–4 gleichzeitige Jobs
16GBGroß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.

VPS memory sizing guide for headless Chromium visual regression jobs: 2GB for a single sequential job, 4GB as the recommended baseline for one to two concurrent jobs, 8GB for three to four concurrent jobs, and 16GB for large suites and parallel pipelines

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.

Teilen

Mehr aus dem Blog

Weiterlesen.

Bereit zum Deployen? Ab 2,48 $/Monat.

Unabhängige Cloud, seit 2008. AMD EPYC, NVMe, 40 Gbps. 14 Tage Geld-zurück-Garantie.