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) figeDate.now(),setTimeoutetrequestAnimationFramecôté page. Indispensable pour tout composant qui affiche « il y a 3 minutes » ou un graphe temps réel. - Lazy-loading : un
fullPagesur 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
concurrencybas 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
browserContextneuf 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églerthresholdpar 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.