Ga naar hoofdinhoud
50% korting alle plannen, beperkte tijd. Vanaf $2.48/mo
13 min left
Developer-tools en DevOps

Visual regression testing self-hosten in je CI-pipeline

S Door Sajjad 13 min leestijd
Self-hosted visual regression testing running in a CI pipeline with BackstopJS and Docker

Op je laptop slagen je visual regression tests gewoon. Je pusht, de pipeline draait, en dezelfde tests laten de browser stilletjes crashen of melden vijftig "wijzigingen" die in werkelijkheid alleen anti-aliasingverschillen zijn. Er is niets aan je code veranderd, maar CI vindt van wel.

Dat gat tussen "werkt lokaal" en "werkt in CI" is bijna altijd een infrastructuurprobleem, geen testprobleem. Om visual regression testing betrouwbaar te self-hosten in een CI-pipeline moet je vier onderdelen correct aan elkaar knopen: een CI-runner met een Docker-executor, een container die zo is geconfigureerd dat headless Chromium niet zonder gedeeld geheugen komt te zitten, een difftool die CI-leesbare rapporten uitspuugt, en een VPS die groot genoeg is voor het geheugen dat headless browsers werkelijk verbruiken.

Deze gids bouwt die stack van begin tot eind op. Aan het eind heb je een werkende .gitlab-ci.yml (of het Gitea-equivalent), een Docker-omgeving waarin Chromium niet meer crasht, BackstopJS dat JUnit-rapporten produceert die je pipeline kan lezen, en een VPS met de juiste maat om het geheel op te draaien.

TL;DR

  • Draai de browser lokaal en in CI in hetzelfde Docker-image. De meeste vals-positieve diffs komen door verschillen in fonts en rendering tussen omgevingen. Zet het image vast en ze verdwijnen.
  • Repareer de standaard van 64MB voor /dev/shm. De standaardgrootte van het gedeelde geheugen in Docker laat de renderer van Chromium verhongeren, waardoor die stil crasht in CI. Zet shm_size: '2gb' in docker-compose.yml, of geef --disable-dev-shm-usage mee aan Chromium.
  • Gebruik BackstopJS als belangrijkste self-hostbare tool. Het is MIT-gelicentieerd (v6.3.25), Docker-first via een --docker-flag en levert JUnit-rapporten. Draai je al Playwright, dan is de ingebouwde toHaveScreenshot() een prima startpunt zonder extra installatie.
  • Reken op 4GB RAM als basis. Wees ruimhartig met geheugen. In de praktijk kan een Chromium-screenshotjob tijdens full-page captures veel meer geheugen verbruiken dan hij in rust lijkt nodig te hebben, dus 4GB RAM is de veiligere basis voor één tot twee sequentiële of licht parallelle jobs.

Wat deze gids niet behandelt

Dit is een bouwgids voor infrastructuur waarvan je al hebt besloten dat je die neerzet. Een paar dingen vallen bewust buiten het bestek:

  • Het is geen volledige toolvergelijking. Deze gids richt zich op de stack die je in een self-hosted CI-omgeving daadwerkelijk kunt beheren: BackstopJS, Docker en je eigen runner.
  • Het behandelt geen self-hosting van Argos. Argos is open source, maar de publieke productflow en documentatie richten zich op de gehoste Argos-app en CI-integraties, niet op een eenvoudig self-hostingpad voor productie. Voor het doel van deze gids is BackstopJS het veiligere uitgewerkte voorbeeld.
  • Het raadt Lost Pixel niet aan. Dat project is in april 2026 gearchiveerd; gebruik BackstopJS voor nieuwe deployments.
  • Het voert de discussie self-hosting versus managed SaaS niet opnieuw. Als je hier bent, heb je Percy of Chromatic al afgestreept.

Vereisten

Een paar dingen moeten klaarstaan voor het eerste commando:

  • Een self-hosted CI-runner, of een plan om er een uit te rollen (komt hieronder aan bod).
  • Docker geïnstalleerd op de runner-host.
  • Een Node.js-project met een bestaand testdoel (een draaiende app-URL of een set componentroutes om vast te leggen).
  • Shell-toegang tot de VPS of host waar de runner draait.

Waarom visual tests lokaal slagen maar breken in CI

De browser die je baseline rendert op macOS is niet de browser die de vergelijking rendert in een Linux CI-container. De documentatie van Playwright is daar botweg over: browserrendering kan verschillen afhankelijk van de host-OS, versie, instellingen, hardware, voedingsbron, headless-modus en andere factoren. De aanbeveling is even direct: voor consistente screenshots draai je tests in dezelfde omgeving waarin de baseline-screenshots zijn gegenereerd.

Die ene aanbeveling is de reden dat de rest van deze opzet bestaat. Drie mismatches verklaren bijna elke valse fout:

  • Verschillen in fonts en anti-aliasing. Je ontwikkelmachine en de CI-container hebben andere fontpakketten en subpixelrendering. Tekstrijke pagina's geven bij elke run diffs, ook als er niets is veranderd.
  • Headless versus headed rendering. Een headed browser en een headless browser kunnen dezelfde pagina net iets anders opmaken.
  • Uitputting van gedeeld geheugen. Dit is de stille. De standaard /dev/shm van Docker is 64MB, en headless Chromium gebruikt gedeeld geheugen voor zijn rendererprocessen. Raakt dat op, dan crasht Chromium zonder bruikbare foutmelding. Je job sterft gewoon of blijft hangen.

De eerste twee los je op door één Docker-image vast te zetten voor zowel het genereren van de baseline als de vergelijking. De derde los je op in het Docker-configuratiegedeelte hieronder. Repareer alle drie en de tests die "flaky in CI" waren, worden deterministisch.

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

Je tool kiezen: BackstopJS of de ingebouwde diffing van Playwright

Draai je al Playwright voor end-to-end tests, dan is de snelste route de ingebouwde toHaveScreenshot()-assertie: geen extra dependencies, diffing op pixelniveau en baselines met platformsuffix uit de doos. Voor een kleine suite is dat een prima eindpunt, en voor teams met ruwweg minder dan vijftig schermen zonder gedeelde componentbibliotheek is het misschien alles wat je ooit nodig hebt.

Waar het spaak loopt, is de reviewworkflow. De diffing van Playwright werkt alleen op pixelniveau en heeft geen PR-review-UI, dus een terechte batch visuele wijzigingen goedkeuren betekent snapshots met de hand opnieuw genereren. Zodra je aantal baselines oploopt, wordt die handmatige goedkeuringslus pijnlijk.

BackstopJS is de speciale tool die precies voor die lus is gebouwd. Het is MIT-gelicentieerd (momenteel v6.3.25), Docker-first via een --docker-flag, en het levert JUnit XML die een CI-runner native leest. De baselineworkflow (een reference genereren, ertegen testen, geaccepteerde diffs goedkeuren als nieuwe baseline) is het operationele model waar de meeste self-hosted VRT-opstellingen uiteindelijk naartoe willen. Voor de rest van deze gids is BackstopJS het uitgewerkte voorbeeld.

Twee tools die je elders aanbevolen ziet, vallen in deze walkthrough af: Argos behandelen we hier niet omdat de publieke productflow draait om de gehoste app en CI-integraties, en Lost Pixel is gearchiveerd. BackstopJS is het veiligere uitgewerkte voorbeeld voor een praktische self-hosted opzet.

AsBackstopJSPlaywright toHaveScreenshot()
PR-reviewworkflowIngebouwd rapport en approve-commandoSnapshots handmatig opnieuw genereren
BaselinebeheerReference-, test- en approve-commando'sSnapshotbestanden per test
Docker-genormaliseerde rendering--docker-flagNeem je eigen image mee
Installatie-overheadAparte dependencyAl aanwezig als je Playwright gebruikt

Wil je een diepere vergelijking over meer tools en criteria, dan behandelt onze vergelijking van BackstopJS, Argos en Lost Pixel de volledige keuze.

Een self-hosted CI-runner uitrollen op een VPS

Het onderdeel dat de renderingconsistentie echt levert, is de Docker-executor: de runner draait elke job in een container die jij definieert, waardoor de browseromgeving elke keer identiek is. Kies het runnerplatform op basis van wat je er verder nog mee wilt doen.

Self-hosted GitLab CE is de zwaardere optie: repositories, CI/CD, registry, issues en merge requests in één instantie. Het belangrijkste onderdeel voor deze gids is de Docker-executor van GitLab Runner, waarmee elke visual-regression-job elke keer in hetzelfde vastgezette container-image draait. GitLab, Gitea, Jenkins, Forgejo, Portainer en Docker zijn allemaal beschikbaar als one-click-deployments op de de Cloudzy-marketplace, wat de installatie aanzienlijk verkort.

Gitea met act-runner is het lichtgewicht alternatief. Gitea is een Git-service op basis van Go met een workflow-engine die compatibel is met GitHub Actions (via act-runner), dus als je team vertrouwd is met de Actions-syntaxis, zet je hiermee een kleine, snelle runner neer.

Wat je ook kiest, het configuratiedoel is hetzelfde: een runner die jobs aanneemt en ze met een Docker-executor uitvoert. Alles wat daarna komt (de containerconfiguratie, BackstopJS, de voorbeeldpipeline) gaat ervan uit dat die executor er is.

Docker zo configureren dat Chromium niet crasht

Dit is de fout die mensen de meeste tijd kost. Docker mount /dev/shm standaard op 64MB. Headless Chromium gebruikt die shared-memory-partitie voor zijn rendererprocessen, en bij een full-page screenshot heeft het veel meer dan 64MB nodig. Raakt dat op, dan sterft de renderer, vaak zonder foutmelding die naar gedeeld geheugen wijst. De job blijft hangen, loopt in een timeout, of meldt een algemene browsercrash.

Er zijn twee oplossingen, en beide werken.

Oplossing A: verhoog de shared-memory-grootte in Compose. De sleutel shm_size bepaalt de grootte van de /dev/shm-partitie van de container. De Docker Compose-bestandsreferentie bevestigt dat shm_size de hoeveelheid gedeeld geheugen instelt die de servicecontainer mag gebruiken. Zet het in het serviceblok:

# docker-compose.yml
services:
  vrt:
    image: backstopjs/backstopjs:6.3.25
    shm_size: '2gb'          # override the 64MB default
    volumes:
      - ./:/src
    working_dir: /src

Oplossing B: zeg Chromium dat het /dev/shm helemaal niet moet gebruiken. De vlag --disable-dev-shm-usage zorgt ervoor dat Chromium shared-memory-bestanden naar /tmp schrijft in plaats van naar de shared-memory-partitie. Geef hem mee in je browser-launchargumenten. In een backstop.json-configuratie is dat engineOptions; in Playwright is dat launchOptions:

// backstop.json (fragment)
{
  "engine": "puppeteer",
  "engineOptions": {
    "args": ["--disable-dev-shm-usage", "--no-sandbox"]
  }
}

Oplossing A is netter als je het Compose-bestand beheert; oplossing B is de draagbare keuze als je alleen bij de browser-launchflags kunt. Beide toepassen kan geen kwaad.

Pro-tip: Gebruik lokaal en in CI exact hetzelfde Docker-image. BackstopJS geeft je dat gratis met de --docker-flag, die de capture in een vastgezet referentie-image draait zodat jouw machine en de runner identiek renderen. Dat is wat de vals-positieven door fonts en anti-aliasing wegneemt: zet het image één keer vast en stop met het najagen van spookdiffs.

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 opzetten voor CI

BackstopJS produceert een JUnit XML-rapport dat je CI-runner leest om de build te laten slagen of falen, en dat is precies waarom je het in een pipeline hangt in plaats van het met de hand te draaien. Installeer het en initialiseer een configuratie:

npm install --save-dev backstopjs
npx backstop init

Wijs backstop.json vervolgens naar de pagina's die je wilt vastleggen en zet het CI-rapport aan:

// 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"]
  }
}

De CI-rapportinstelling is wat JUnit XML uitspuugt. Gebruik de --docker-flag als je deze commando's lokaal vanaf je hostmachine draait. Binnen een CI-job die al het backstopjs/backstopjs-image gebruikt, draai je in plaats daarvan direct backstop test. De baselineworkflow bestaat uit drie commando's:

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

Eén kanttekening bij baselinebeheer: elke terechte UI-wijziging betekent approve draaien om de nieuwe screenshots te zegenen. Boven ruwweg honderd scenario's is die goedkeuringsstap een echte operationele kostenpost: iemand moet de diffs met het oog beoordelen en bepalen welke bedoeld zijn, en die last groeit mee met het team dat het gebruikt. Dat onderhoud is de werkelijke prijs van visual regression testing op schaal, en het is verstandig daar rekening mee te houden voordat je een grote suite vastlegt.

Een werkende CI-pipelineconfiguratie

De pipeline draait in twee logische delen: genereer de baseline één keer (of op aanvraag) en vergelijk er daarna bij elke wijziging tegen. Het voorbeeld hieronder is een GitLab CI-configuratie met een Docker-executor, waarin de shared-memory-fix al is toegepast via de launchflags in backstop.json.

# .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

De junit-regel geeft het rapport door aan GitLab zodat fouten in de merge-request-UI verschijnen; de paths-regel bewaart de diff-bitmaps zodat je kunt zien wat er werkelijk is veranderd. Op Gitea met act-runner vertalen dezelfde stappen zich naar een workflow in Actions-stijl:

# .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

Let op de containeroptie --shm-size=2gb in de Gitea-workflow. Dat is dezelfde shared-memory-fix als oplossing A, toegepast op het niveau van de jobcontainer, waar geen apart Compose-bestand te bewerken is.

Je VPS dimensioneren voor headless Chromium

RAM is hier de beperkende factor, niet CPU. Het CPU-gebruik van headless Chromium is schoksgewijs (het piekt tijdens een capture en is daartussen stil), dus een bescheiden aantal cores houdt het prima bij. Geheugen is het plafond dat bepaalt hoeveel jobs je tegelijk kunt draaien. Een headless Chromium-proces zit in rust rond 300–500MB en klimt naar 1–2GB tijdens het vastleggen van een full-page screenshot, dus de bepalende factor is RAM per gelijktijdige job.

RAMGeschikt voor
2GBKrappe ondergrens: één sequentiële job
4GBAanbevolen basis: 1–2 gelijktijdige jobs
8GB3–4 gelijktijdige jobs
16GBGrote suites en parallelle pipelines

Deze cijfers zijn praktijkrichtlijnen uit echte CI-runs, geen leveranciersbenchmark, dus behandel ze als globale startpunten en houd je eigen piekgeheugen in de gaten.

Kern van dit hoofdstuk: 4GB RAM is de betrouwbare basis; reken ongeveer 2GB extra ruimte per extra gelijktijdige visual regression job.

Het addertje onder het gras bij self-hosted VRT is dat een runner met te weinig geheugen niet luidruchtig faalt, maar Chromium stil laat crashen, precies het symptoom dat deze hele opzet moet voorkomen. Genoeg speling inplannen is de goedkoopste verzekering die je kunt kopen. Wil je liever niet vanaf nul een runner-host bouwen, dan biedt de marketplace van Cloudzy one-click deployments voor self-hosted GitLab en self-hosted Gitea waarmee je binnen een paar minuten een draaiende CI-instantie hebt, op een VPS die je precies kunt afstemmen op de headless-Chromium-ruimte die je suite nodig heeft.

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

Veelgestelde vragen

Hoe zet ik visual regression testing op in een CI/CD-pipeline?

Installeer BackstopJS, wijs backstop.json naar je doelpagina's en zet de CI-rapportoptie aan zodat JUnit XML wordt weggeschreven. Gebruik backstop reference --docker en backstop test --docker als je vanaf je lokale machine draait, zodat screenshots binnen het vastgezette Docker-image renderen. Draai de job in CI binnen het backstopjs/backstopjs-image en roep direct backstop test aan, en publiceer daarna het JUnit-rapport uit backstop_data/ci_report/xunit.xml. Gebruik een self-hosted runner met een Docker-executor, met minstens 4GB RAM voor headless Chromium.

Hoeveel RAM heeft headless Chromium nodig in een CI-container?

Een headless Chromium-proces gebruikt in rust ongeveer 300–500MB en piekt naar 1–2GB tijdens het vastleggen van een full-page screenshot. Reken voor betrouwbare CI op ongeveer 4GB RAM per één tot twee gelijktijdige jobs, plus ruwweg 2GB voor elke extra parallelle job. CPU is schoksgewijs en niet de beperkende factor; RAM bepaalt hoeveel jobs je tegelijk kunt draaien.

Waarom slagen mijn visual regression tests lokaal maar falen ze in CI?

Browserrendering verschilt per omgeving: fonts, anti-aliasing, headless versus headed modus en de OS veranderen allemaal het resultaat. De oplossing is het genereren van de baseline en de vergelijking in hetzelfde Docker-image te draaien, zodat beide identiek renderen. De --docker-flag van BackstopJS doet dat door captures in een vastgezet referentie-image te draaien.

Hoe los ik de /dev/shm-fout op in Chromium onder Docker-CI?

De standaard /dev/shm van Docker is 64MB, te klein voor de renderer van headless Chromium, wat stille crashes veroorzaakt. Verhoog hem met shm_size: '2gb' in docker-compose.yml (of --shm-size=2gb op de jobcontainer), of geef --disable-dev-shm-usage mee in de launchflags van Chromium zodat het shared-memory-bestanden naar /tmp schrijft.

Moet ik Playwright toHaveScreenshot gebruiken of een aparte visual regression tool?

De ingebouwde toHaveScreenshot() van Playwright volstaat voor kleine suites: minder dan ruwweg vijftig schermen, geen gedeelde componentbibliotheek en geen behoefte aan een PR-review-UI. Stap over op een aparte tool zoals BackstopJS zodra je een echte goedkeuringsworkflow voor baselines, een reviewrapport en Docker-genormaliseerde rendering nodig hebt, wat belangrijk wordt naarmate je aantal baselines groeit.

Wordt Lost Pixel nog onderhouden?

Nee. Lost Pixel is in april 2026 gearchiveerd en is geen kandidaat voor nieuwe deployments. Gebruik BackstopJS voor een self-hosted opzet voor visual regression testing.

Delen

Meer van de blog

Blijf lezen.

Klaar om uit te rollen? Vanaf $2,48/mnd.

Onafhankelijke cloud, sinds 2008. AMD EPYC, NVMe, 40 Gbps. 14 dagen niet-goed-geld-terug.