22 septembre 2025 · Tommy Bordas

Architecture d'un SaaS de visual regression testing, comment j'ai construit VizProof

saasvisual-regression-testingarchitectureplaywrightangular

Construire un SaaS de visual regression testing, c'est capturer, comparer et stocker des milliers de screenshots de façon déterministe et à grande échelle. Voici l'architecture complète de VizProof : capture figée sous Playwright, moteur de diff perceptuel (pixelmatch, SSIM), gestion des baselines, files de workers BullMQ, stockage immuable S3 et une GitHub Action qui bloque la PR, avec le code des décisions clés.

Le problème que résout le visual regression testing

Les tests unitaires valident la logique, mais ils ne voient pas qu'un bouton a viré au gris, qu'une marge a sauté, qu'une icône a disparu ou qu'un composant déborde sur mobile. Le visual regression testing capture une image de référence (la baseline), la compare à chaque build et signale les pixels qui ont changé.

Le défi n'est pas de comparer deux images : n'importe quel diff pixel le fait. Le vrai défi, c'est de le faire de façon fiable, rapide et à grande échelle, sans noyer l'équipe sous les faux positifs. Un outil qui crie au loup à chaque build se fait désactiver en deux semaines. Toute l'architecture qui suit existe pour tenir cette promesse : un diff signalé = un changement visuel réel qui mérite un coup d'œil humain.

Vue d'ensemble de l'architecture

Couche Rôle Stack
API Réception des runs, auth, quotas Node.js + Fastify
Orchestration Files de jobs, retries, concurrence BullMQ (Redis)
Workers Rendu navigateur + capture Playwright
Diff engine Comparaison perceptuelle pixelmatch + SSIM
Stockage Screenshots & baselines S3 (objets immuables)
Front Revue des diffs, approbation Angular (signals + OnPush)
CI Déclenchement & statut GitHub Action / webhook

Le principe directeur : chaque screenshot est un objet immuable, identifié par un hash de contenu. On ne modifie jamais une image, on en crée une nouvelle. Ça simplifie le cache, la reprise sur erreur, la déduplication et l'audit. On peut toujours répondre à « à quoi ressemblait l'écran au commit abc123 ? ».

import { createHash } from 'node:crypto';

// Identité d'un snapshot = métadonnées + hash du contenu PNG.
// Deux captures identiques pointent vers le même objet S3 (dédup gratuite).
function snapshotKey(meta: SnapshotMeta, png: Buffer): string {
  const contentHash = createHash('sha256').update(png).digest('hex');
  const { projectId, story, viewport, commit } = meta;
  return `snapshots/${projectId}/${story}/${viewport}/${commit}/${contentHash}.png`;
}

À retenir : séparez l'identité logique (projet/story/viewport/commit) du hash de contenu. La première sert à retrouver et comparer ; la seconde déduplique le stockage et garantit l'immuabilité.

1. Capture déterministe

La première source de faux positifs, c'est le non-déterminisme : animations, polices non chargées, curseurs clignotants, dates dynamiques, lazy-loading d'images, scrollbars. La capture doit geler tout ça avant le shot.

async function captureDeterministic(page: Page, opts: CaptureOpts): Promise<Buffer> {
  // 1. Fige animations, transitions et caret via une feuille de style injectée
  await page.addStyleTag({
    content: `*, *::before, *::after {
      animation-duration: 0s !important;
      animation-delay: 0s !important;
      transition-duration: 0s !important;
      caret-color: transparent !important;
    }`,
  });

  // 2. Gèle l'horloge : Date.now() et les dates dynamiques deviennent stables
  await page.clock.setFixedTime(new Date('2025-01-01T00:00:00Z'));

  // 3. Attend la fin du chargement réseau et des polices
  await page.waitForLoadState('networkidle');
  await page.evaluate(() => document.fonts.ready);

  // 4. Le flag animations: 'disabled' de Playwright fige aussi les anim. en cours
  return page.screenshot({ fullPage: true, animations: 'disabled' });
}

Quelques pièges concrets rencontrés sur VizProof :

  • Polices web : sans document.fonts.ready, le screenshot part avec la police de fallback, puis la vraie police s'affiche au render suivant : diff garanti à chaque run. Embarquer les polices localement plutôt que via un CDN supprime aussi la latence variable.
  • Dates et horloge : page.clock.setFixedTime() (Playwright ≥ 1.45) fige Date.now(), setTimeout et requestAnimationFrame côté page. Indispensable pour tout composant qui affiche « il y a 3 minutes » ou un graphe temps réel.
  • Lazy-loading : un fullPage sur une longue page ne déclenche pas forcément le chargement des images sous la ligne de flottaison. Forcez un scroll programmatique de haut en bas avant le shot.

À retenir : 80 % des faux positifs disparaissent en gelant les animations, en figeant l'horloge et en attendant document.fonts.ready. Stabilisez la capture avant d'optimiser le diff. Un moteur de diff perceptuel ne rattrapera jamais une capture instable.

2. Le moteur de diff : pixel, perceptuel et structurel

Comparer pixel par pixel strictement est trop naïf : un rendu sub-pixel différent entre deux machines (GPU, antialiasing) déclenche une alerte sur du contenu visuellement identique. La bonne approche combine un seuil de tolérance perceptuel par pixel avec un seuil global sur la surface qui a réellement changé.

Méthode Détecte Faux positifs Coût CPU
Diff pixel strict (égalité) Toute différence, au bit près Très élevés Faible
pixelmatch (seuil + anti-AA) Différences visibles à l'œil Faibles Faible
SSIM (structure, luminance, contraste) Changements de structure Très faibles Moyen

pixelmatch est la brique de base. Deux paramètres comptent et sont souvent confondus :

  • threshold (0 à 1) : la distance colorimétrique par pixel au-delà de laquelle deux pixels sont jugés différents. Plus c'est bas, plus c'est sensible. Défaut : 0.1.
  • includeAA : à false (défaut), pixelmatch détecte et ignore les pixels d'anti-aliasing, exactement ce qu'on veut pour absorber les différences de rendu de texte entre machines.
import pixelmatch from 'pixelmatch';

// diff.data reçoit l'image de différence (pixels changés surlignés)
const changedPixels = pixelmatch(
  baseline.data, current.data, diff.data,
  width, height,
  { threshold: 0.1, includeAA: false }, // sensibilité par pixel + ignore l'AA
);

// Seuil GLOBAL, distinct du threshold par pixel : quelle part de l'écran a bougé ?
const changedRatio = changedPixels / (width * height);
const status = changedRatio > project.tolerance ? 'changed' : 'passed';

Bien distinguer les deux seuils est essentiel : threshold décide si un pixel a changé, changedRatio décide si le screenshot a changé. La tolérance globale (0.2 % par défaut) est configurable par projet : une landing page marketing tolère moins de dérive qu'un dashboard data dense où quelques pixels bougent en permanence.

Au-delà du pixel : SSIM et zones

Pixelmatch dit combien de pixels ont changé, pas où ni si ça compte. Deux raffinements changent la donne :

  • SSIM (Structural Similarity Index) compare luminance, contraste et structure sur des fenêtres glissantes plutôt que pixel à pixel. Il distingue un vrai changement structurel (un bloc qui se déplace) d'un bruit de rendu uniforme. Plus coûteux, on le réserve aux écrans à fort taux de faux positifs.
  • Détection de zones (régions) : plutôt qu'un ratio global, on regroupe les pixels changés en bounding boxes connexes. Un diff concentré dans une seule région est plus parlant qu'un pourcentage, et permet de pointer la revue humaine directement sur la zone concernée.

Masquage des zones dynamiques

Certaines zones sont irréductiblement non déterministes : carrousel de pub, compteur live, avatar utilisateur, carte interactive. On les masque avant le diff. Playwright sait peindre des masques natifs à la capture, ce qui les neutralise dès la source :

await page.screenshot({
  fullPage: true,
  animations: 'disabled',
  mask: [page.locator('[data-vrt-ignore]'), page.locator('.live-ticker')],
  maskColor: '#FF00FF', // couleur de masque constante → jamais de diff sur ces zones
});

La convention data-vrt-ignore dans le markup laisse les développeurs déclarer eux-mêmes les zones à ignorer, sans toucher à la config du SaaS.

3. Gestion des baselines et workflow d'approbation

Un diff détecté n'est pas forcément un bug : c'est peut-être un changement voulu. Le cœur du produit, c'est la boucle d'approbation humaine qui promeut un nouveau rendu en baseline. On la modélise comme une machine à états explicite, avec des transitions autorisées et rien d'autre.

type SnapshotStatus =
  | 'pending'    // capturé, pas encore comparé
  | 'passed'     // identique à la baseline (dans la tolérance)
  | 'changed'    // diff détecté, attend une revue humaine
  | 'approved'   // validé → devient la nouvelle baseline
  | 'rejected';  // régression confirmée → build rouge

// Transitions autorisées : tout le reste est rejeté par le service.
const TRANSITIONS: Record<SnapshotStatus, SnapshotStatus[]> = {
  pending:  ['passed', 'changed'],
  passed:   [],
  changed:  ['approved', 'rejected'],
  approved: [],
  rejected: ['changed'], // ré-ouverture après un nouveau push
};

function transition(snap: Snapshot, to: SnapshotStatus, actor: UserId): Snapshot {
  if (!TRANSITIONS[snap.status].includes(to)) {
    throw new Error(`Transition interdite : ${snap.status} → ${to}`);
  }
  return { ...snap, status: to, reviewedBy: actor, reviewedAt: new Date() };
}

Promouvoir une baseline ne remplace jamais l'ancienne image : on écrit un nouvel objet immuable et on déplace un simple pointeur baseline → snapshotId. L'historique reste intact, et un rollback est un changement de pointeur.

Côté Angular, la revue des diffs s'appuie sur des signals et OnPush pour rester fluide même avec des centaines de screenshots :

@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class ReviewBoardComponent {
  private snapshots = signal<Snapshot[]>([]);
  filter = signal<SnapshotStatus>('changed');

  // Recalcul mémoïsé : ne re-render que les snapshots du filtre courant
  visible = computed(() =>
    this.snapshots().filter((s) => s.status === this.filter()),
  );

  pendingReview = computed(() =>
    this.snapshots().filter((s) => s.status === 'changed').length,
  );
}

4. Mise à l'échelle avec une file de workers

Un projet peut générer 500 screenshots par build (stories × viewports × navigateurs). Les lancer en série prendrait plusieurs minutes. La solution : une file de jobs BullMQ avec des workers parallèles à concurrence maîtrisée.

import { Queue, Worker } from 'bullmq';

const connection = { host: 'redis', port: 6379 };
const captureQueue = new Queue('capture', { connection });

new Worker(
  'capture',
  async (job) => {
    const { url, viewport, runId } = job.data;
    const png = await captureDeterministic(/* ... */);
    const key = await uploadToS3(png, runId);
    await enqueueDiff(runId, key); // chaîne vers la file de diff
  },
  {
    connection,
    concurrency: 4, // 4 contextes navigateur en parallèle par worker
  },
);

Subtilité importante : dans BullMQ, concurrency est défini par instance de worker, et la doc recommande des valeurs de 100 à 300 pour des jobs purement I/O. Ici, on est à l'opposé : chaque job lance un navigateur réel, gourmand en CPU et en RAM (200-400 Mo par contexte Chromium). Une concurrence trop haute fait thrasher la machine et ralentit tout. La bonne stratégie :

  • garder concurrency bas par worker (4-6 selon les vCPU disponibles) ;
  • scaler horizontalement en ajoutant des instances de worker (conteneurs), pas en gonflant la concurrence ;
  • réutiliser une seule instance de navigateur et ne créer qu'un browserContext neuf par job (isolation sans le coût d'un cold start).

5. Intégration CI en une étape

L'adoption dépend de la friction. L'objectif : une seule action dans le pipeline, qui bloque la PR en cas de régression non approuvée et poste un lien direct vers la revue.

- name: Visual regression
  uses: vizproof/action@v1
  with:
    token: ${{ secrets.VIZPROOF_TOKEN }}
    build: ${{ github.sha }}
    base: ${{ github.event.pull_request.base.sha }}
    fail-on: changed   # bloque la PR si un diff n'est pas approuvé

L'Action appelle l'API, attend le résultat du run, puis sort en code non nul si des snapshots sont en changed. Côté GitHub, un status check « VizProof : visual regression » devient bloquant via les branch protection rules. Le commentaire de PR pointe vers le tableau de revue : un clic, on approuve ou on rejette, le check repasse au vert.

# Exit code piloté par le statut du run → bloque ou laisse passer le merge
exit_code=$(curl -s -H "Authorization: Bearer $VIZPROOF_TOKEN" \
  "https://api.vizproof.io/runs/$RUN_ID/exit-code")
exit "$exit_code"

Maîtriser les faux positifs

C'est la métrique qui fait vivre ou mourir le produit. Une checklist des leviers, du plus rentable au plus fin :

  • Geler animations, transitions et caret (feuille de style injectée).
  • Attendre les polices (document.fonts.ready) et les héberger localement.
  • Figer l'horloge (page.clock.setFixedTime) pour neutraliser dates et timers.
  • Ignorer l'anti-aliasing (includeAA: false) et régler threshold par projet.
  • Masquer les zones dynamiques (data-vrt-ignore, masque natif Playwright).
  • Verrouiller l'environnement de rendu : même version de navigateur, même viewport, même deviceScaleFactor, conteneur identique entre baseline et build.
  • Tolérance globale par projet plutôt qu'un seuil unique pour tout le SaaS.

À retenir : un faux positif coûte plus cher qu'un faux négatif. À chaque alerte ignorée, l'équipe perd confiance dans l'outil. Mieux vaut un diff manqué de temps en temps qu'un outil qu'on finit par désactiver.

Coût et scalabilité

Le poste de coût dominant d'un SaaS de visual regression, ce n'est pas le diff (quelques ms de CPU) : c'est le rendu navigateur et le stockage.

Poste Pourquoi ça coûte Levier
Rendu navigateur Chromium = CPU + RAM par capture Concurrence maîtrisée, scale horizontal, autoscaling sur la profondeur de file
Stockage S3 Des milliers de PNG immuables qui s'accumulent Déduplication par hash de contenu, lifecycle policy, classe Infrequent Access
Bande passante Téléchargement des images pour le diff Diff côté worker proche du bucket, pas de round-trip client
Redis (BullMQ) État des files Jobs courts, nettoyage automatique (removeOnComplete)

Deux décisions changent radicalement la facture :

  • La déduplication par hash : si un écran ne bouge pas entre deux commits, le PNG identique n'est stocké qu'une fois. Sur un projet stable, le taux de dédup dépasse souvent 90 %.
  • L'autoscaling piloté par la profondeur de file : on dimensionne le nombre de workers sur le backlog BullMQ, pas sur un nombre fixe. En dehors des heures de build, on retombe à zéro worker. On ne paie le compute que pendant les runs.

Résultats

  • Temps de capture : 500 screenshots en ~90 s (vs ~6 min en série).
  • Faux positifs : divisés par 12 après gel des animations, horloge figée et seuil perceptuel.
  • Taux de déduplication du stockage : > 90 % sur un projet mature, sans purge.
  • Régressions visuelles attrapées avant prod : la valeur réelle du produit. Un bug visuel coûte bien plus cher en production qu'un check rouge en CI.

En savoir plus

J'ai détaillé le contexte produit, les arbitrages et les métriques business dans l'étude de cas complète : VizProof, SaaS de Visual Regression Testing.

Vous construisez un SaaS ou un outil interne et vous voulez challenger son architecture (capture, files de jobs, stockage, intégration CI) ? Parlons-en.