Votre pipeline CI est vert : tests unitaires, tests fonctionnels, analyse statique.
Vous déployez. Et la production tombe sur une erreur que personne n'avait vue venir :
une extension PHP absente de l'image, un composer install --no-dev qui a
retiré un bundle encore référencé, un entrypoint qui échoue sur un volume en lecture
seule. Le problème est structurel : le container que vous déployez n'est pas
ce que vous avez testé. Vos tests valident du code ; la production exécute
un artefact.
Sur une application que je maintiens pour un grand opérateur télécom, déployée sur OpenShift, nous avons éliminé cette classe d'incidents en exécutant la suite de tests end-to-end Playwright directement contre l'image Docker de production, dans GitLab CI, avant toute publication. Si la suite échoue, l'image n'est ni publiée, ni déployée. Voici le retour d'expérience complet : l'architecture, les choix de conception, et les pièges très concrets rencontrés en route.
Pourquoi tester l'artefact et pas seulement le code
Entre le code source validé par la CI et le container qui tourne en production, il y a une chaîne de transformations entière :
- un
composer install --no-dev -oqui change le contenu devendor/; - un build front (Vite, Webpack…) dont les artefacts doivent être copiés au bon endroit ;
- une image de base avec ses versions de PHP, ses extensions, sa configuration Apache/FPM ;
- un entrypoint qui joue les migrations et des commandes métier au démarrage ;
- des contraintes d'exécution spécifiques à la plateforme cible — sur OpenShift, le container tourne avec un utilisateur non-root arbitraire.
Chacun de ces maillons peut casser sans qu'aucun test unitaire ne le détecte, puisque les tests unitaires tournent sur le code source, avec les dépendances de dev, dans un environnement qui ne ressemble pas à la production. Tester l'image elle-même, c'est tester le produit fini — exactement ce qui sera poussé sur le cluster.
Le contexte
L'application est un back-office métier en Symfony 6.4 / PHP 8.4, rendu côté serveur (Twig, HTMX, quelques îlots React), avec MySQL et Redis. Le pipeline GitLab CI suit une chaîne CI/CD classique :
- build — installation des dépendances PHP et build des assets front ;
- test — tests unitaires et fonctionnels, lint, analyse statique ;
- package-build — construction de l'image Docker de production, poussée sous un tag snapshot propre au pipeline ;
- package-test — c'est ici que vivent les e2e ;
- package-publish — promotion du snapshot en tag de release ;
- deploy — déploiement automatique en staging, puis production en validation manuelle.
Le placement du job e2e n'est pas un détail : il s'exécute après la construction de l'image mais avant sa publication. Un échec bloque mécaniquement tout ce qui suit — l'image défectueuse n'atteint jamais le registre de release, donc jamais staging, donc jamais la production.
L'architecture du job : l'image de prod comme service GitLab CI
GitLab CI permet d'attacher des services à un job : des containers démarrés à côté du container principal, joignables par leur alias réseau. C'est le mécanisme qu'on utilise habituellement pour fournir un MySQL à des tests fonctionnels. L'idée centrale du montage est de l'utiliser pour démarrer l'image de production fraîchement construite, à côté de ses dépendances :
e2e:
stage: package-test
# le job tourne dans l'image Playwright officielle :
# Node + Chromium + dépendances système déjà installés
image: mcr.microsoft.com/playwright:v1.60.0-noble
services:
- name: mysql:8.0
alias: mysql
# base jetable : la durabilité est inutile, on optimise la vitesse
command:
- "--skip-log-bin"
- "--innodb-flush-log-at-trx-commit=0"
- "--innodb-doublewrite=0"
- "--sync-binlog=0"
- name: redis:7
alias: redis
# capture les emails sortants pour les assertions (API HTTP sur :8025)
- name: axllent/mailpit:latest
alias: mailpit
# l'image construite par CE pipeline (tag snapshot),
# PAS la dernière release publiée
- name: $SNAPSHOT_IMAGE
alias: web
entrypoint: [ "bash", "-c" ]
command:
- >
cd /var/www/app &&
until php bin/console doctrine:database:create -e test -n --if-not-exists;
do echo "waiting for mysql…"; sleep 2; done &&
php bin/console doctrine:migrations:migrate -e test -n --allow-no-migration &&
exec supervisord -c /etc/supervisor/conf.d/app.conf
variables:
APP_ENV: test
MAILER_DSN: smtp://mailpit:1025
E2E_BASE_URL: http://web:8080
MAILPIT_URL: http://mailpit:8025
before_script:
- npm -C e2e/ ci
# attend la fin des migrations puis charge le jeu de données
- node e2e/ci/load-seed.mjs
script:
- npm -C e2e/ test
artifacts:
when: always
paths: [ 'e2e/playwright-report/', 'e2e/test-results/' ]
reports:
junit: e2e/reports/junit.xml
Le job lui-même tourne dans l'image Playwright officielle, qui embarque Node, Chromium
et toutes les dépendances système du navigateur. Rien n'est installé au runtime — une
contrainte dure dans notre cas, car le runner GitLab tourne sur Kubernetes avec un
utilisateur non-root qui n'a pas le droit d'installer quoi que ce soit. Seule
vigilance : le tag de l'image Playwright doit rester aligné sur la version de
@playwright/test du package-lock.json, sous peine de
décalage navigateur/runner.
Le piège du tag : tester l'image du pipeline courant
Premier piège, et il est sournois. Notre première version référençait le tag de
release de l'image (:master). Or ce tag n'est promu qu'après
l'étape de test… Le job testait donc l'image du pipeline précédent :
toujours verte, forcément — elle avait déjà été validée. Une suite e2e qui teste la
mauvaise image est pire qu'inutile : elle donne un faux sentiment de sécurité. La
correction consiste à référencer le tag snapshot poussé par le
package-build du pipeline en cours.
Rendre l'image de production démarrable en environnement de test
L'image de prod ne contient ni les dépendances de dev, ni les outils de fixtures.
Pour qu'elle puisse démarrer en APP_ENV=test, trois conditions doivent
être réunies.
1. Les bundles de dev ne doivent exister qu'en dev
Si un bundle de fixtures (Alice, par exemple) est déclaré pour les environnements
dev et test, le kernel Symfony refusera de démarrer en test
dans l'image de prod, puisque le package n'est pas dans vendor/. La
solution est de restreindre ces bundles à dev uniquement :
return [
// …
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true],
Nelmio\Alice\Bridge\Symfony\NelmioAliceBundle::class => ['dev' => true],
Fidry\AliceDataFixtures\Bridge\Symfony\FidryAliceDataFixturesBundle::class => ['dev' => true],
Hautelook\AliceBundle\HautelookAliceBundle::class => ['dev' => true],
];
C'est le déblocage clé de tout le montage : l'environnement test devient
un environnement que l'image de production sait exécuter, avec son
vendor/ sans dépendances de dev.
2. Neutraliser les dépendances externes
L'application parle à un annuaire d'entreprise (authentification OIDC), à une
passerelle SMS, à un stockage S3. Aucun de ces services n'est joignable — ni
souhaitable — depuis un job CI. En environnement de test, chaque pont vers
l'extérieur est remplacé par une implémentation factice, en surchargeant
simplement l'alias de l'interface dans un bloc when@test :
when@test:
services:
# chaque interface pointe vers son implémentation factice
App\Bridge\Directory\DirectoryInterface: '@App\Bridge\Directory\FakeDirectory'
App\Bridge\Sms\SmsInterface: '@App\Bridge\Sms\FakeSms'
App\Bridge\Storage\StorageInterface: '@App\Bridge\Storage\FakeStorage'
Même logique pour l'authentification : en test, un fallback http_basic
avec des identifiants factices remplace le flux OIDC complet. Côté Playwright, les
httpCredentials sont envoyés préventivement — aucun test ne contient
d'étape de login, et les specs qui vérifient les droits d'un autre profil changent
simplement d'identifiants avec test.use().
3. Adapter l'entrypoint
L'entrypoint de production fait plus que servir l'application : dump des variables d'environnement, installation des assets, migrations, commandes métier de synchronisation. En CI, la majorité de ces étapes est inutile ou impossible. Le service GitLab surcharge donc l'entrypoint pour ne garder que l'essentiel : attendre MySQL, créer le schéma via les migrations, puis lancer le serveur — comme dans l'extrait YAML plus haut. On teste bien les binaires, la configuration et le code de l'image ; seul le bootstrap est adapté au contexte.
Des données de test déterministes — sans outil de fixtures dans l'image
Les fixtures Alice étant désormais réservées au dev, comment peupler la base ? La réponse tient en deux temps :
- les fixtures restent écrites en YAML Alice, lisibles et maintenables, dans le dépôt ;
- un script les charge en environnement de dev dans une base jetable, puis exporte un dump SQL data-only versionné (
fixtures/seed.sql), régénéré à chaque évolution des fixtures.
Le script de régénération s'appuie sur la pile de test locale : il déroule les migrations dans une base dédiée, y charge les fixtures Alice (en dev, le seul environnement où le bundle existe), puis exporte les données seules :
#!/usr/bin/env bash
# Régénère fixtures/seed.sql, le dump data-only chargé
# par la pile de test locale et par le job CI.
set -euo pipefail
compose() { docker compose -f compose.test.yaml "$@"; }
scratch="seed_regen"
db_url="mysql://root:toor@mysql:3306/${scratch}?serverVersion=8.0"
# Alice n'existe qu'en dev : on y construit le schéma via les
# migrations, puis on charge les fixtures dans la base jetable
compose exec -T -e APP_ENV=dev -e DATABASE_URL="${db_url}" app bash -lc '
php bin/console doctrine:database:drop -e dev -n --if-exists --force &&
php bin/console doctrine:database:create -e dev -n &&
php bin/console doctrine:migrations:migrate -e dev -n --allow-no-migration &&
php bin/console hautelook:fixtures:load -e dev -n --purge-with-truncate
'
# export data-only et rejouable : pas de CREATE TABLE,
# des REPLACE plutôt que des INSERT
compose exec -T mysql mysqldump -uroot -ptoor \
--no-create-info --complete-insert --replace \
--single-transaction --skip-comments --skip-triggers \
"${scratch}" > fixtures/seed.sql
# la base jetable ne survit pas à la régénération
compose exec -T -e APP_ENV=dev -e DATABASE_URL="${db_url}" \
app php bin/console doctrine:database:drop -e dev -n --if-exists --force
Deux options de mysqldump font tout le travail :
--no-create-info n'exporte que les lignes (le schéma reste l'affaire
exclusive des migrations), et --replace rend le dump rejouable sur une
base déjà peuplée. Le fichier produit est versionné avec le code : une revue de
code montre exactement quelles données de test ont changé.
En CI, le job charge ce dump une fois les migrations terminées. Subtilité : l'image
Playwright n'a pas de client mysql, et on ne peut rien y installer. Le
chargement passe donc par un petit script Node avec le driver mysql2,
qui réessaie jusqu'à ce que le schéma existe :
const sql = await readFile(seedPath, "utf8");
// L'app doit booter, attendre MySQL, puis dérouler toute la chaîne de
// migrations (~200 migrations, ~100 s) avant qu'une table n'existe :
// le budget de retry doit couvrir ce délai, pas seulement le warm-up DB.
const maxAttempts = 240;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const connection = await mysql.createConnection(config);
await connection.query(sql);
await connection.end();
process.exit(0);
} catch {
// base pas prête ou schéma pas encore migré — on attend
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
Le commentaire sur le budget de retry est un piège vécu : dimensionné au départ pour un simple démarrage de MySQL, le délai d'attente expirait pendant la chaîne de migrations. Le temps à couvrir est celui du chemin complet — pull de l'image, boot, attente MySQL, migrations — pas celui du composant le plus rapide.
Le piège le plus inattendu : un TLD préchargé HSTS
Le plus beau piège du projet mérite sa propre section. Le service applicatif
s'appelait initialement app — un alias naturel. Et tous les tests
échouaient en CI avec des erreurs TLS incompréhensibles, alors que l'application
répondait parfaitement en HTTP.
services:
- name: $SNAPSHOT_IMAGE
# alias: app # KO : http://app:8080 → erreur TLS dans Chromium
alias: web # OK : http://web:8080
variables:
E2E_BASE_URL: http://web:8080
Explication : .app est un TLD réel, possédé par Google, et inscrit en
HSTS preload dans Chromium. Un hôte nommé app est donc
automatiquement promu en HTTPS par le navigateur — qui tente alors une poignée de
main TLS sur un port qui ne sert que du HTTP en clair. Renommer l'alias en
web a suffi. Coût de diagnostic : bien supérieur au coût de la
correction, comme souvent.
Asserter sur les emails sortants
L'application envoie des notifications par email, et la suite doit pouvoir les vérifier. En environnement de test, le mailer Symfony est routé en synchrone (pas de worker Messenger en CI) et pointe vers Mailpit, démarré comme service. Les tests interrogent ensuite son API HTTP :
import { clearMail, waitForMail } from "../support/mailpit";
await clearMail();
// … action qui déclenche une notification …
const mail = await waitForMail((m) => m.Subject.includes("Nouveau service"));
expect(mail.Bcc?.map((a) => a.Address)).toContain("owner@example.com");
L'envoi d'email fait ainsi partie du périmètre testé, dans les conditions de l'image de production — y compris la configuration du transport et le rendu des templates.
Reproduire le même environnement en local
Un échec e2e en CI doit pouvoir être débogué confortablement. Un
compose.test.yaml dédié reproduit le montage à l'identique : MySQL sur
tmpfs avec la durabilité coupée, Redis sans persistance, Mailpit, le même dump SQL,
le même APP_ENV=test et le même .env.test versionné. La
pile est éphémère par construction : chaque docker compose up repart
d'un état propre et déterministe, et Playwright se lance depuis la machine hôte
contre l'application conteneurisée — y compris en mode --headed ou avec
l'inspecteur pour rejouer un scénario pas à pas.
CI et local partagent ainsi la même définition de l'environnement de test ; seules quelques variables contextuelles (URL de base, DSN Mailpit) diffèrent. C'est ce qui évite la double maintenance — et les écarts qui finissent toujours par apparaître entre deux configurations parallèles.
Ce que ça change concrètement
La suite compte une cinquantaine de scénarios Playwright : smoke tests, contrôle d'accès par profil, rendu de toutes les pages d'administration, CRUD pilotés par des descripteurs déclaratifs, règles de validation, notifications email. Chaque pipeline la déroule contre l'image candidate, en quelques minutes. Cette couverture s'est construite progressivement — la même démarche que pour introduire des tests automatisés sur un projet existant : d'abord les smoke tests, puis les parcours critiques, puis le reste.
- Le déploiement OpenShift devient un non-événement. L'image déployée a déjà démarré, migré sa base et servi de vraies pages dans un vrai navigateur.
- Le staging reste sain. Une image cassée n'y arrive jamais ; le staging sert à la recette métier, pas à découvrir les régressions techniques.
- Les erreurs de packaging sont attrapées à la source. Extension manquante, asset non copié, dépendance retirée par
--no-dev, migration défectueuse : tout cela casse le pipeline, pas la production. - Le passage en production manuel se fait sereinement. Le clic final promeut un artefact dont le comportement a été observé, pas seulement compilé.
Conclusion
Tester l'image de production avant de la déployer ne demande ni outillage exotique ni infrastructure dédiée : des services GitLab CI, une image Playwright, un dump SQL versionné et trois aménagements dans la configuration Symfony. L'essentiel du travail est conceptuel — décider que l'unité testée est l'artefact, pas le code — et se loger au bon endroit du pipeline, entre la construction de l'image et sa publication.
La démarche relève de la même logique que la sécurisation d'une application web en production : déplacer la découverte des problèmes le plus en amont possible, là où ils coûtent le moins cher. Le retour sur investissement, lui, se mesure à chaque déploiement qui ne réserve aucune surprise. Sur une application métier critique, c'est exactement le genre d'ennui que l'on recherche.