Détecter les régressions visuelles dans la CI avec Playwright et Docker
Sur un gros site public, le front bouge tout le temps : une migration Tailwind par-ci, un composant React par-là, un bloc CMS qui change de gabarit. Et comme toujours avec le CSS, la modification d’une classe qui semblait anodine peut très bien décaler un bloc trois pages plus loin, sans que personne ne s’en rende compte avant la mise en production.
Sur un de nos projets, nous avions déjà des tests Behat pour le fonctionnel, et des tests PHPUnit pour le métier. Mais aucun de ces tests ne disait « la page d’accueil ne ressemble plus à la page d’accueil ». C’est exactement ce que font les tests de non-régression visuelle, et Playwright le fait très bien nativement.
Dans cet article, nous allons voir la stack que nous avons mise en place sur ce projet : les tests eux-mêmes, les tasks Castor pour les piloter, le passage par Docker pour avoir un rendu stable entre les machines de l’équipe, et enfin comment nous postons les images de diff directement dans un commentaire de la pull request.
Section intitulée le-principe-code-tohavescreenshot-codeLe principe : toHaveScreenshot()
Playwright, la solution que nous utilisons déjà pour nos tests E2E, propose une assertion faite pour ça : toHaveScreenshot(). Elle prend une capture de la page, la compare avec l’image de référence commitée dans le dépôt, et échoue si les deux diffèrent trop. En l’occurrence, Playwright vérifie si le nombre de pixels différents entre les 2 images est inférieur à un seuil configuré.
Notre fichier application/e2e/screenshots.spec.ts couvre les pages structurantes du site : la home, la page de résultats de recherche, une page de détail, etc. Plusieurs pages, autant d’images de référence, et de quoi attraper l’immense majorité des régressions CSS.
Un test ressemble à ça :
test('homepage screenshot', async ({ page }) => {
await page.goto(homeUrl);
// The search form is a React component that mounts client-side;
// wait for it so the layout below does not shift.
await page.locator('#tab-search').waitFor({ state: 'visible' });
await stabilize(page);
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
animations: 'disabled',
maxDiffPixels: 100,
// The header image is picked at random server-side, so mask it.
mask: [page.getByTestId('homepage-header-image')],
});
});
Écrire le test en lui-même est donc assez trivial. Toute la difficulté de l’exercice est ailleurs : il faut que la page soit déterministe. Un test visuel qui échoue une fois sur trois ne sert à rien, car au bout de deux semaines toute l’équipe relance le job sans même regarder. Nous avons donc passé pas mal de temps, non pas à écrire les tests, mais à supprimer une par une toutes les sources de variation.
Section intitulée attendre-que-la-page-soit-vraiment-stableAttendre que la page soit vraiment stable
Le piège classique : la capture est prise pendant que la page finit de se construire. Images en lazy loading, blocs asynchrones, polices web qui provoquent un reflow au moment où elles arrivent… La capture est techniquement valide, mais elle ne correspond à rien de reproductible.
D’où ce petit helper, appelé dans tous les tests :
// Wait for the page to be visually stable before taking a full-page screenshot:
// network idle (lazy images / async blocks) + web fonts loaded (avoids reflow).
async function stabilize(page: Page): Promise<void> {
await page.waitForLoadState('networkidle');
await page.evaluate(() => document.fonts.ready);
}
Et quand ça ne suffit pas, on attend explicitement l’élément qui pose problème. Sur la home, c’est le formulaire de recherche (un composant React monté côté client) ; sur une autre page, c’est un Swiper qui se réorganise après le networkidle :
await page.locator('.js-swiper-expertises.c-swiperinitialized').first().waitFor({ state: 'visible' });
La classe c-swiperinitialized n’est ajoutée qu’une fois le carrousel initialisé : c’est donc un bon signal pour savoir que le rendu final est atteint.
Section intitulée masquer-ce-qui-est-volontairement-aleatoireMasquer ce qui est volontairement aléatoire
Certaines zones ne seront jamais stables, et c’est normal : c’est le produit qui le veut. L’image d’en-tête de la home est tirée au sort côté serveur. Sur la page d’un point de vente, le bloc FAQ s’affiche aléatoirement, et les horaires d’ouverture mettent en avant le jour courant… qui change tous les jours.
Plutôt que de chercher à contourner le problème, on peut simplement demander à Playwright de masquer ces zones : elles seront recouvertes d’un aplat avant la comparaison.

Pour parvenir à cela, il faut lister les éléments à masquer directement dans la config de Playwright :
mask: [
page.getByTestId('faq-block'), // display randomly
page.getByTestId('store-timetable'), // current day open by default (changes daily)
],
Nous utilisons ici des data-testid plutôt que des classes CSS : cela donne un point d’accroche stable, qui ne bougera pas à la prochaine refonte du style.
Info
Vous pourriez aussi choisir de masquer vous-même certains éléments, en incluant une feuille de style dédiée aux tests et qui ferait un display: none !important; visibility: hidden !important; sur les éléments ciblés par exemple.
Section intitulée fixer-le-viewport-et-tolerer-une-poignee-de-pixelsFixer le viewport et tolérer une poignée de pixels
Deux derniers réglages, dans playwright.config.ts et dans les tests :
use: {
ignoreHTTPSErrors: true,
/* Fixed viewport to keep screenshot dimensions deterministic across machines. */
viewport: { width: 1280, height: 900 },
trace: 'on-first-retry',
},
Un viewport fixe garantit que les dimensions de la capture ne dépendent pas de la machine. Quant au maxDiffPixels (50 sur la plupart des pages, 100 sur la home qui est plus chargée), il laisse passer les micro-variations d’antialiasing sans laisser passer un vrai décalage de bloc. C’est un curseur à régler : trop bas, les tests deviennent flaky ; trop haut, on rate des régressions. Ces valeurs se sont stabilisées à l’usage.
Section intitulée un-rendu-stable-grace-a-dockerUn rendu stable grâce à Docker
Il reste malgré tout une source de variation, et c’est probablement la plus importante : une capture d’écran n’est pas seulement le résultat de votre HTML et de votre CSS, c’est aussi le résultat du moteur de rendu de la machine qui a pris la capture. Rendu des polices, antialiasing, sous-pixels : macOS et Linux ne produisent tout simplement pas les mêmes pixels.
Playwright en est d’ailleurs conscient, puisqu’il suffixe les images de référence par plateforme. Voici le contenu de notre dossier de snapshots :
application/e2e/screenshots.spec.ts-snapshots/
├── detail-page-chromium-linux.png
├── homepage-chromium-linux.png
├── list-page-chromium-linux.png
├── pro-homepage-chromium-linux.png
├── store-homepage-chromium-linux.png
└── store-page-chromium-linux.png
Notez bien le suffixe -chromium-linux. Or, notre équipe est mixte : certains développent sur macOS, d’autres sur Linux. Si chacun lance Playwright sur son hôte, il faut soit commiter deux jeux d’images (-darwin et -linux) et les maintenir en double, soit accepter que les collègues sur Mac échouent systématiquement sur des tests pourtant verts en CI. Aucune des deux options n’est satisfaisante.
Heureusement, la solution est celle que nous appliquons déjà à tout le reste sur ce projet : tout tourne dans Docker. Les navigateurs Playwright sont installés dans un conteneur dédié à tout le tooling du projet (Composer, nodejs, etc.), jamais sur l’hôte, et les tests sont exécutés dedans. Ainsi, que ce soit les développeurs sous Mac ou Linux, ou bien depuis le runner de CI, c’est toujours le même environnement de rendu qui est exécuté. Les images de référence sont générées une fois, dans le conteneur sous Linux, et valent pour tout le monde.
Quant aux binaires des navigateurs, ils atterrissent dans le cache par défaut de Playwright ($HOME/.cache/ms-playwright). Sur notre projet, ce dossier est un volume monté : ils survivent ainsi aux reconstructions de conteneur et ne sont pas re-téléchargés à chaque lancement.
Info
Sur ce projet, nos tests ne tournent volontairement que sur Chrome. Donc nous évitons de télécharger Firefox et WebKit pour rien. Pour cela, on ajoute la config suivante dans le fichier playwright.config.ts :
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
Puis on demande à Playwright d’installer uniquement Chromium :
yarn playwright install chromium'
Grâce au cache natif et à l’utilisation d’un seul navigateur, nous gagnons une quinzaine de secondes de temps d’exécution du job E2E dans notre CI.
Section intitulée piloter-les-tests-avec-castorPiloter les tests avec Castor
Comme souvent quand nos projets nécessitent de lancer des commandes, nous avons mis en place une task Castor pour simplifier la DX. Le but est que personne n’ait jamais besoin de savoir dans quel conteneur, ni avec quelles variables d’environnement, Playwright doit tourner :
#[AsTask(description: 'E2E Playwright Tests')]
function e2e(?string $filter = null, bool $updateScreenshots = false): void
{
io()->section('Running E2E Playwright Tests...');
// Browsers are downloaded in the default cache directory ($HOME/.cache/ms-playwright), which
// is a mounted volume: the download only happens once. Only chromium is needed by the tests.
io()->comment('Installing Playwright browsers...');
docker_compose_run('yarn playwright install chromium');
$command = 'yarn playwright test';
// Optionally filtered to a single spec
if (null !== $filter) {
$command .= ' ' . escapeshellarg($filter);
}
if ($updateScreenshots) {
io()->comment('Running tests and updating screenshots...');
$command .= ' --update-snapshots';
}
docker_compose_run($command);
}
Pour résumer, cette task va :
- s’assurer que le navigateur utilisé par les tests est installé ;
- lancer les tests, éventuellement filtrés sur un seul fichier de spec ;
- et, si on le lui demande, régénérer les images de référence plutôt que de les comparer.
Le workflow au quotidien tient alors en trois commandes :
# Lancer les comparaisons
castor qa:e2e --filter screen
# En cas d’échec : ouvrir les images de diff (les différences ressortent en rouge)
castor qa:e2e-diff
# Si les différences sont légitimes (modification CSS, ajout de contenu…) :
# régénérer les images de référence
castor qa:e2e --update-screenshots
La task qa:e2e-diff ne fait pas grand-chose, mais elle évite d’avoir à fouiller dans test-results/ pour trouver le bon PNG :
#[AsTask(description: 'Open the diff images of the last failing screenshots')]
function e2e_diff(): void
{
$diffs = glob(\dirname(__DIR__) . '/application/test-results/*/*-diff.png') ?: [];
if ([] === $diffs) {
io()->success('No screenshot diff found. All screenshots match.');
return;
}
foreach ($diffs as $diff) {
io()->writeln('Opening ' . basename($diff));
open($diff);
}
}
Notez que Playwright n’écrit ces fichiers -diff.png que pour les captures ayant réellement échoué. Il n’y a donc rien à filtrer : ce qui se trouve dans le dossier est exactement ce qui est cassé.
Section intitulée poster-les-diffs-dans-la-pull-requestPoster les diffs dans la pull request
Tout ceci fonctionne très bien en local. En CI, en revanche, l’expérience était nettement moins agréable : un job rouge, un message « expected 50 pixels, got 3400 », et il fallait ensuite aller récupérer les images à la main pour comprendre ce qui avait changé.
Nous avons donc ajouté une étape supplémentaire à la CI : quand un screenshot échoue, les images sont postées dans un commentaire de la pull request, avec l’attendu, l’obtenu et le diff côte à côte dans un tableau Markdown. La personne qui relit voit le problème directement en ouvrant la PR, sans avoir à cliquer sur « Détails ».

Le job GitHub Actions reste très simple, puisque toute la logique est déportée dans des tasks Castor :
- name: E2E Playwright Tests
run: castor qa:e2e
- name: Report E2E screenshot failures on the PR
if: ${{ failure() && github.event_name == 'pull_request' }}
run: castor qa:e2e-report-failures
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_PR_NUMBER: ${{ github.event.pull_request.number }}
E2E_RUN_ID: ${{ github.run_id }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
- name: Clear E2E screenshot report on the PR
if: ${{ success() && github.event_name == 'pull_request' }}
run: castor qa:e2e-clear-report
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_REPOSITORY: ${{ github.repository }}
Info
Mettre la logique dans une task Castor plutôt que dans le YAML de GitHub Actions a un avantage non négligeable : on peut la lancer en local avec une option --dry-run qui construit et affiche le commentaire sans rien envoyer. Débugger un rendu Markdown sans avoir à pousser un commit pour chaque essai, c’est appréciable.
Il reste une contrainte à contourner : on ne peut pas afficher une image dans un commentaire GitHub sans que celle-ci soit accessible via une URL publique. Nous hébergeons donc les images sur notre préproduction, en réutilisant l’accès SSH dont on se sert déjà pour le déploiement :
$remoteUser = 'deploy';
$remoteHost = 'preprod-web01';
$remoteDir = '/var/www/sites/www.example.com/current/web/images/_e2e';
// Careful: pick a host that is NOT behind a Basic auth, or GitHub cannot fetch the images.
$publicBaseUrl = 'https://static-preprod.example.com/images/_e2e';
$retentionDays = 30;
Castor fournit justement tout ce qu’il faut pour piloter une machine distante, avec ssh_run(), ssh_upload() et ssh_download(). Une fois les images rangées dans un dossier de staging local, l’envoi tient en deux appels :
// Upload the whole staging directory. ssh_upload() always runs scp with "-r", and copying a
// source directory onto a non-existing destination creates it: remove any leftover from a
// previous attempt on the same run first, otherwise scp would nest it inside itself.
$remoteRunDir = $remoteDir . '/' . $runKey;
ssh_run(
\sprintf('rm -rf %s && mkdir -p %s', escapeshellarg($remoteRunDir), escapeshellarg($remoteDir)),
host: $remoteHost,
user: $remoteUser,
);
ssh_upload($staging, $remoteRunDir, host: $remoteHost, user: $remoteUser);
Info
Nous utilisons des runners GitHub qui sont self-hostés et tournent sur une machine située sur l’infra du client et qui a donc accès à l’instance de préproduction. Dans la plupart des situations, ce n’est pas le cas, il vous faudra donc trouver comment rendre accessible publiquement ces images (hébergement S3-like, service dédié, etc).
Le commentaire est ensuite construit à la main en Markdown :
$body .= "## ❌ Régression visuelle E2E\n\n";
$body .= \sprintf("Des screenshots ont changé sur ce run ([logs](%s)).\n\n", $runUrl);
foreach ($screenshots as $screenshot) {
$base = \sprintf('%s/%s/%s/%s', $publicBaseUrl, $runKey, $screenshot['testDir'], $screenshot['name']);
$body .= \sprintf("### `%s`\n\n", $screenshot['name']);
$body .= "| Attendu | Obtenu | Diff |\n| --- | --- | --- |\n";
$body .= \sprintf(
"| %s | %s | %s |\n\n",
null !== $screenshot['expected'] ? \sprintf('', $base) : '—',
null !== $screenshot['actual'] ? \sprintf('', $base) : '—',
\sprintf('', $base),
);
}
Quelques détails supplémentaires méritent d’être mentionnés.
Section intitulée un-commentaire-sticky-plutot-qu-un-nouveau-a-chaque-runUn commentaire « sticky » plutôt qu’un nouveau à chaque run
Une PR avec plusieurs allers-retours finirait vite avec une dizaine de commentaires de robot. Pour éviter ça, nous plaçons un marqueur HTML invisible en tête du corps du message :
// Hidden marker used to identify the sticky E2E screenshots report comment on a PR.
const E2E_REPORT_MARKER = '<!-- e2e-screenshots-report -->';
Il suffit ensuite de parcourir les commentaires de la PR à la recherche de ce marqueur :
const E2E_GITHUB_API_BASE = 'https://api.github.com';
function e2e_find_report_comment(array $options, string $repo, string $pr): ?int
{
$page = 1;
do {
$listUrl = \sprintf('%s/repos/%s/issues/%s/comments?per_page=100&page=%d', E2E_GITHUB_API_BASE, $repo, $pr, $page);
$comments = http_request('GET', $listUrl, $options)->toArray();
foreach ($comments as $comment) {
if (str_contains($comment['body'] ?? '', E2E_REPORT_MARKER)) {
return $comment['id'];
}
}
++$page;
} while (100 === \count($comments));
return null;
}
Les $options sont communes à tous les appels, et regroupent l’authentification et les en-têtes attendus par l’API :
function e2e_github_options(string $token): array
{
return [
'auth_bearer' => $token,
'headers' => [
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
],
];
}
Et selon qu’on a trouvé un commentaire existant ou non, on fait un PATCH sur celui-ci ou un POST d’un nouveau :
$options = e2e_github_options($token);
$existingId = e2e_find_report_comment($options, $repo, $pr);
if (null !== $existingId) {
$url = \sprintf('%s/repos/%s/issues/comments/%d', E2E_GITHUB_API_BASE, $repo, $existingId);
http_request('PATCH', $url, [...$options, 'json' => ['body' => $body]])->getContent();
io()->success(\sprintf('Updated PR #%s comment with %d screenshot(s).', $pr, \count($screenshots)));
} else {
$url = \sprintf('%s/repos/%s/issues/%s/comments', E2E_GITHUB_API_BASE, $repo, $pr);
http_request('POST', $url, [...$options, 'json' => ['body' => $body]])->getContent();
io()->success(\sprintf('Posted a comment on PR #%s with %d screenshot(s).', $pr, \count($screenshots)));
}
La même fonction de recherche est réutilisée par la task qa:e2e-clear-report : quand les tests repassent au vert, on retrouve le commentaire par son marqueur et on le supprime (DELETE), pour que la PR ne garde pas la trace d’un problème déjà corrigé.
Section intitulée dedoublonner-les-retriesDédoublonner les retries
En CI, Playwright réessaie deux fois (retries: process.env.CI ? 2 : 0) et écrit les résultats de chaque tentative dans des dossiers frères <test>-retryN. Sans traitement, la même régression apparaîtrait donc trois fois dans le commentaire. Nous regroupons les images par test et par capture, en ne gardant que la tentative la plus élevée :
// Retries live in sibling "<test>-retryN" directories; strip the suffix for a stable key.
$testDir = basename($dir);
$retry = 0;
if (preg_match('/^(.*)-retry(\d+)$/', $testDir, $matches)) {
$testDir = $matches[1];
$retry = (int) $matches[2];
}
$key = $testDir . '/' . $name;
if (isset($screenshots[$key]) && $screenshots[$key]['retry'] >= $retry) {
continue;
}
Section intitulée purger-les-vieilles-imagesPurger les vieilles images
Enfin, comme nous poussons des images sur un serveur partagé à chaque échec, il faut éviter que le dossier ne grossisse indéfiniment. Un find sur les dossiers de run trop anciens fait l’affaire. Notez le allowFailure : le ménage ne doit pas faire échouer le rapport s’il se passe mal.
// Prune old run directories so the shared folder does not grow forever.
ssh_run(
\sprintf(
'find %s -mindepth 1 -maxdepth 1 -type d -mtime +%d -exec rm -rf {} +',
escapeshellarg($remoteDir),
$retentionDays,
),
host: $remoteHost,
user: $remoteUser,
allowFailure: true,
);
Au bout du compte, toute la logique pour poster les régressions visuelles en commentaire dans GitHub représente environ 200 lignes de PHP, mais cette amélioration nous fait gagner du confort au quotidien : on passe d’un « le job E2E est rouge » à un « ah oui, le footer a pris 4px » sans quitter la page de la PR.
Section intitulée le-cas-des-fixtures-aleatoiresLe cas des fixtures aléatoires
Il reste une dernière source de variation, et c’est celle qui nous a demandé le plus de tâtonnements.
Nos fixtures utilisent nelmio/alice, et donc Faker, ce qui implique de l’aléatoire : des titres, des prix, des descriptions, des noms générés à la volée. Or une page de détail ne peut évidemment pas produire une capture stable si le prix affiché change à chaque chargement des fixtures.
Nous avions déjà fait en sorte d’éviter les soucis d’aléatoire dès le début du projet car les tests Behat avaient le même besoin d’avoir des contenus stables dans le temps, et la solution tient en une ligne de configuration :
# application/config/packages/nelmio_alice.yaml
when@dev: &dev
nelmio_alice:
locale: 'fr_FR' # Default locale for the Faker Generator
seed: 42
when@test: *dev
Avec un seed fixe, Faker devient déterministe : les mêmes fixtures rechargées produisent exactement les mêmes données, et donc exactement les mêmes pixels. Problème réglé, en apparence.
Sauf que le déterminisme d’un générateur pseudo-aléatoire est positionnel. Le seed garantit une séquence de valeurs, mais pas l’affectation d’une valeur donnée à un objet donné. Si vous ajoutez une entité au milieu d’un fichier de fixtures, par exemple pour un test Behat qui n’a rien à voir avec le visuel, vous consommez un tirage supplémentaire, et tout ce qui vient après décale d’un cran dans la séquence. Les prix changent, les titres changent, et les captures d’écran deviennent rouges à cause d’un test fonctionnel sans aucun rapport.
Le symptôme est assez déroutant la première fois qu’on le rencontre : la PR ne touche pas une ligne de CSS, et pourtant les tests visuels échouent.
La règle que nous nous sommes donnée est simple : quand une capture change à cause d’un décalage de fixtures, on change la fixture impactée puis on régénère l’image. Concrètement, on identifie la donnée qui a bougé sur la page (un prix, un titre, un nom) et on lui donne une valeur en dur au lieu de la laisser à Faker :
# Avant : la valeur dépend de la position du tirage dans la séquence.
title: '<sentence()>'
price: '<numberBetween(100000, 900000)>'
# Après : la valeur est figée, et ne bougera plus jamais.
title: 'Une valeur figée pour les tests de screenshots'
price: 245000
Le commentaire de PR est précieux pour ça : il montre immédiatement quelle donnée a changé. Constater qu’un prix est passé de 245 000 € à 312 000 € prend deux secondes, là où le déduire d’un compteur de pixels est impossible.
L’intérêt de procéder ainsi, c’est que la correction est définitive et que l’effort est réparti dans le temps. Nous ne figeons pas toutes les fixtures d’un coup – ce serait un gros chantier, et une bonne partie n’apparaît de toute façon dans aucune capture. Nous figeons uniquement celles qui nous ont réellement posé problème, au moment où elles nous le posent. Au fil des PR, les données visibles sur les pages sous test deviennent progressivement déterministes, et ce type d’échec se raréfie de lui-même.
Section intitulée en-resumeEn résumé
Grâce à Playwright, Docker et Castor, nous avons pu mettre en place une détection des régressions visuelles qui reste simple à l’usage :
toHaveScreenshot()fait tout le travail de comparaison, sans outil externe ni service tiers à payer ;- le passage par Docker garantit un rendu identique sur les postes de l’équipe et en CI, quel que soit l’OS ;
- les tasks Castor masquent la plomberie et installent leurs dépendances toutes seules ;
- le déterminisme est obtenu par un ensemble de petits réglages : viewport fixe, attente explicite de stabilisation, masques sur les zones volontairement aléatoires et seed sur les fixtures ;
- les diffs postés directement dans la pull request rendent chaque échec compréhensible en un coup d’œil.
Quelques captures d’écran seulement, et quelques centaines de lignes de configuration : ce n’est évidemment pas une couverture exhaustive du site, et ce n’est pas le but. Mais depuis leur mise en place, les régressions de mise en page sur les pages structurantes sont détectées avant la mise en production, et non plus après.