Les points clés de ce retour d'expérience :
- Complémentarité des niveaux de test : Validation rapide des règles métier et formulaires via PHPUnit (KernelTestCase, TypeTestCase) associée à la vérification graphique des flux complets par Playwright.
- Isolation stricte sous Docker : Conteneurisation de Playwright (mcr.microsoft.com/playwright:v1.58.2-noble) garantissant l'exécution sans dépendances Node local ni décalage de versions de navigateurs entre dev et CI.
- Seeding d'état par API de test dédiée : Contournement de la fragilité des parcours UI longs grâce à des endpoints comme ApiSetupBookingController qui créent et pré-approuvent les réservations directement en base.
- Exécution unifiée via Makefile : Commande unique make pw attendant la mise à disposition du conteneur (/tmp/ready) avant de lancer la suite E2E.
- Débogage guidé par les artefacts : Capture automatique des enregistrements vidéo et des traces d'exécution uniquement lors des échecs de scénarios pour une investigation rapide.
- Protection des environnements : Verrouillage hermétique des endpoints d'initialisation de test réservés exclusivement aux contextes dev et test locaux.
1. Introduction : le défi de la régression sur les parcours transactionnels
Sur une plateforme web de mise en relation et de réservation, certains parcours utilisateurs ont un impact direct sur l'activité : la recherche d'offres, la réservation d'un logement, l'enregistrement des coordonnées bancaires et le paiement effectif. Une régression sur l'un de ces flux peut interrompre la conversion des utilisateurs ou bloquer le versement des fonds aux hébergeurs.
Lorsque la base de code évolue et s'enrichit de nouvelles fonctionnalités, s'appuyer uniquement sur des vérifications manuelles devient risqué et chronophage. D'un autre côté, créer des tests automatisés E2E (End-to-End) reposant exclusivement sur des clics de navigateur depuis la page de connexion jusqu'à la confirmation finale produit souvent des tests lents, instables et sensibles aux moindres modifications d'interface.
Dans cet article, je détaille la stratégie de test mise en place sur l'application : l'association de suites PHPUnit rapides pour l'intégration et la validation des contrats, et d'un conteneur Docker Playwright dédié aux parcours graphiques critiques. Nous verrons notamment comment l'exposition d'endpoints API d'initialisation restreints au mode test permet d'isoler l'exécution des scénarios graphiques sans dépendre de données de recette fragiles.
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister me concernant : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Les écueils des approches classiques de test E2E
Avant l'harmonisation de l'infrastructure de test, la validation des parcours critiques se heurtait à trois obstacles majeurs :
- Divergence entre l'environnement de dev et la CI : L'exécution de tests navigateurs en local dépendait des binaires Chromium ou Firefox installés sur la machine du développeur, engendrant des comportements différents entre Linux, macOS et les runners d'intégration continue.
- Fragilité liée au chaînage d'actions UI : Pour tester l'étape de paiement, le scénario devait naviguer sur le site, soumettre une recherche, envoyer un message, attendre la validation de l'hébergeur, puis enfin ouvrir la page de paiement. Le moindre délai réseau ou changement de sélecteur en amont faisait échouer le test avant même d'atteindre le composant à vérifier.
- Effets de bord sur les données : Réutiliser des comptes ou des annonces existantes entraînait des collisions de données lorsqu'un test modifiait l'état d'une entité déjà engagée dans un autre processus.
3. Tableau comparatif : validation manuelle vs tests conteneurisés par API
| Axe d'analyse | Approche E2E Pure (UI classique) | Approche Hybride (Docker + API Setup Playwright) |
|---|---|---|
| Préparation des données | Enchaînement de 5 à 10 écrans UI (connexion, recherche, formulaire) | Appel d'API de setup instantané (POST /api/playwright/setup-...) |
| Temps d'exécution par spec | Lent (20 à 45 secondes par parcours) | Rapide (2 à 5 secondes par scénario ciblé) |
| Reproductibilité | Variable selon les versions de navigateurs installés localement | Déterministe via l'image Docker Playwright standardisée |
| Couverture de code | Difficile d'isoler les règles métier internes | Règles validées par PHPUnit, rendu et interactions validés par Playwright |
4. Architecture globale du pipeline de test
Le pipeline de test s'articule autour de deux niveaux complémentaires :
5. Intégration de Playwright dans Docker Compose
Pour éviter d'imposer l'installation de Node.js et des navigateurs Playwright sur les machines hôtes ou dans les environnements de dev des développeurs, l'exécuteur Playwright est isolé dans un service Docker Compose.
Le fichier docker-compose.yml intègre un service dédié nommé playwright :
services:
# Service Web Server (Nginx)
web:
networks:
dev:
aliases: # Hôtes de dev utilisés par Playwright pour fixer les cookies et l'en-tête HOST
- fr-fr.appplatform.local
- appplatform.local
# Service Playwright conteneurisé
playwright:
build: docker/playwright
container_name: appplatform_playwright
user: "1000:1000"
ipc: host
volumes:
- ./../:/var/www
- /var/www/appplatform/node_modules
working_dir: /var/www/appplatform
networks:
- dev
command: sh -c "touch /tmp/ready && tail -f /dev/null"
healthcheck:
test: ["CMD", "test", "-f", "/tmp/ready"]
interval: 2s
timeout: 5s
retries: 30
start_period: 10s
Le Dockerfile associé s'appuie sur l'image officielle Playwright :
FROM mcr.microsoft.com/playwright:v1.58.2-noble
WORKDIR /var/www/appplatform
RUN npm install -g playwright @playwright/test
ENV NODE_PATH=/usr/lib/node_modules
Pour lancer la suite de tests E2E depuis la machine hôte sans entrer manuellement dans le conteneur, une cible est ajoutée dans le Makefile de l'application :
pw: ## Exécution de la suite de tests Playwright dans Docker
@docker exec appplatform_playwright sh -c "until [ -f /tmp/ready ]; do echo 'Waiting for Playwright to be ready...'; sleep 1; done"
docker exec appplatform_playwright npx playwright test
6. Configuration et ciblage de l'environnement de test
Le fichier de configuration playwright.config.ts définit les règles d'exécution, la gestion des erreurs et l'URL racine de l'application locale :
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/Playwright',
outputDir: './coverage/playwright/results',
/* Exécution en parallèle des fichiers de test */
fullyParallel: true,
/* Interdiction d'oublier .only sur les builds de CI */
forbidOnly: !!process.env.CI,
/* Tentatives de rejeu en CI uniquement */
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { outputFolder: './coverage/playwright/report' }]],
use: {
baseURL: 'https://fr-fr.appplatform.local',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
7. Initialisation contrôlée d'état par API (ApiSetupBookingController)
L'un des apports majeurs de l'architecture réside dans l'exposition d'un endpoint d'initialisation de données dédié aux tests. Plutôt que de faire exécuter à Playwright 10 étapes graphiques pour créer une réservation à l'état "pré-approuvée par l'hôte", un appel HTTP POST positionne directement la base de données dans l'état exact requis par le scénario de paiement.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Controller\playwright;
use App\Application\Builder\BookingBuilder;
use App\Application\Task\Transition\BookingTransitionTask;
use App\Application\Vo\Booking\NewBookingVo;
use App\Domain\Enum\BookingTripReason;
use App\Domain\Enum\Workflow\BookingTransition;
use App\Infrastructure\Controller\front\MainControllerV2;
use App\Infrastructure\Repository\Doctrine\ListingRepository;
use App\Infrastructure\Repository\Doctrine\UserRepository;
use DateInterval;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
final class ApiSetupBookingController extends MainControllerV2
{
public const ROUTE = 'api_playwright_setup_payment_relation';
public function __construct(
private readonly UserRepository $userRepository,
private readonly ListingRepository $listingRepository,
private readonly EntityManagerInterface $em,
private readonly BookingBuilder $relationBuilder,
private readonly BookingTransitionTask $relationTransitionTask,
) {
}
#[Route(
path: '/api/playwright/setup-payment-booking',
name: self::ROUTE,
methods: ['POST'],
priority: 1,
)]
public function __invoke(Request $request): JsonResponse
{
// Verrouillage de sécurité : exécutable uniquement sur serveur local en environnement de test
if (!$this->serverParams->isLocalServer() || !$this->serverParams->isAppEnvTest()) {
throw new NotFoundHttpException('Not found.');
}
try {
$data = \json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$tenantEmail = $data['tenantEmail'] ?? 'user2@example.com';
$guest = $this->userRepository->findByCode($tenantEmail);
if (!$guest) {
throw new NotFoundHttpException('Guest not found');
}
// Recherche d'une annonce disponible éligible pour la réservation
$listing = $this->listingRepository->findOneForPlaywrightPaymentRelation($guest);
if (!$listing) {
throw new NotFoundHttpException('No suitable Listing found');
}
$startDate = new DateTime();
$startDate->add(new DateInterval('P1M'));
$endDate = clone $startDate;
$endDate->add(new DateInterval('P35D'));
$newRelationVo = new NewBookingVo();
$newRelationVo->setTenant($guest)
->setAd($listing)
->setStartDate($startDate)
->setEndDate($endDate)
->setNbTenants(1)
->setMessage('Hello, I would like to book your room for next month. (Playwright Test API)')
->setTripReason(BookingTripReason::HOLIDAY);
// Création de la réservation via le service métier du domaine
$booking = $this->relationBuilder->createByTenant($newRelationVo, 'playwright_test', 'playwright');
// Exécution immédiate de la transition workflow "pré-approbation par l'hôte"
$this->relationTransitionTask->withRelation($booking)
->withFrom(__CLASS__)
->withTransition(BookingTransition::HOST_PRE_APPROVES)
->withContext([])
->exec();
$this->em->refresh($booking);
return new JsonResponse([
'status' => 'ok',
'relationId' => $booking->getCode(),
'listingCode' => $listing->getCode(),
]);
} catch (Exception $e) {
return new JsonResponse(['status' => 'ko', 'error' => $e->getMessage()], 400);
}
}
}
8. Rédaction du scénario de test E2E (payment.spec.ts)
Grâce au helper d'authentification et à l'endpoint de setup, le scénario de test E2E du tunnel de paiement devient lisible et se concentre uniquement sur les validations d'interface :
Helper d'authentification (tests/Playwright/helpers/auth.helper.ts) :
import { Page, expect } from '@playwright/test'
export async function login(page: Page, email: string, pass: string): Promise {
await page.goto('/#login-popup')
const modal = page.locator('#js-login-modal .modal')
await expect(modal).toBeVisible({ timeout: 5000 })
await page.fill('#login-email', email)
await page.fill('#login-pass', pass)
await page.click('#js-auth-login-submit')
await expect(page.locator('#js-dropdown-btn')).toBeVisible({ timeout: 10000 })
}
Spec E2E du paiement (tests/Playwright/payment.spec.ts) :
import { test, expect } from '@playwright/test'
import { login } from './helpers/auth.helper.ts'
test.describe('Payment Flow', () => {
test('should create a booking and proceed to payment', async ({ page }) => {
// 1. Authentification du locataire
await login(page, 'user2@example.com', 'a')
// 2. Initialisation d'une réservation pré-approuvée via l'API dédiée
const response = await page.request.post('/api/playwright/setup-payment-booking', {
data: {
tenantEmail: 'user2@example.com'
}
})
expect(response.ok()).toBeTruthy()
const responseData = await response.json()
const relationId = responseData.relationId
// 3. Navigation directe vers le détail de la messagerie
await page.goto(`/myspace/inbox/detail/guest/${relationId}`)
// 4. Action de paiement
const paymentBtnSelector = 'button.btn-primary, a.btn-primary, .tst-payment-btn'
const inboxPaymentBtn = page.locator(paymentBtnSelector).filter({ hasText: /Procéder au paiement/i }).first()
await expect(inboxPaymentBtn).toBeVisible()
await inboxPaymentBtn.click()
// 5. Validation de la redirection vers la page de formulaire de paiement
await expect(page).toHaveURL(/\/myspace\/booking\/paymentSchedulePayment\/\d+/)
const finalPaymentBtn = page.locator('#payment-form-submit')
await expect(finalPaymentBtn).toBeVisible()
await finalPaymentBtn.click()
// 6. Assertion finale : retour sur la messagerie avec la réservation confirmée
await expect(page).toHaveURL(`/myspace/inbox/detail/guest/${relationId}`)
})
})
9. Le socle complémentaire des tests PHPUnit
Si Playwright valide l'interactivité graphique et la continuité des redirections HTTP, la validation rapide des contraintes de saisie et des transformations de formulaires repose sur PHPUnit.
Par exemple, pour les formulaires bancaires ou les objets de transfert de données (DTOs), un test TypeTestCase s'exécute en quelques millisecondes et valide l'ensemble des DataProviders :
<?php
declare(strict_types=1);
namespace App\Tests\Infrastructure\Form\Payout\PaymentAccount;
use App\Infrastructure\Form\Payout\PaymentAccount\RibDetailsType;
use Symfony\Component\Form\Test\TypeTestCase;
class RibDetailsTypeTest extends TypeTestCase
{
/**
* @dataProvider provideValidIbanData
*/
public function testSubmitValidForm(array $formData): void
{
$form = $this->factory->create(RibDetailsType::class);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$this->assertTrue($form->isValid());
}
}
10. Enseignements, limites et bonnes pratiques
La mise en œuvre de cette stratégie hybride apporte un haut niveau de confiance avant chaque déploiement. Néanmoins, elle implique de suivre quelques règles de rigueur :
- Sécuriser les endpoints de seeding : L'un des risques majeurs d'un controller comme ApiSetupBookingController serait son exposition en production. La double condition $this->serverParams->isLocalServer() et $this->serverParams->isAppEnvTest() est indispensable pour retourner immédiatement une exception HTTP 404 en dehors des environnements locaux autorisés.
- Éviter la sur-dépendance à l'UI dans les tests E2E : Les tests Playwright doivent se concentrer sur les parcours utilisateur critiques et les assertions visuelles essentielles. La validation des cas aux limites et des combinaisons de saisie doit être déléguée aux suites PHPUnit.
- Exploiter les artefacts en cas d'échec : En conservant les captures d'écran et les enregistrements vidéo uniquement sur échec (screenshot: 'only-on-failure'), les développeurs disposent d'un diagnostic visuel immédiat en CI sans surcharger le stockage.