Tests e2e — écrire un nouveau test
Ce fichier doit rester synchronisé avec les conventions réelles de
e2e/specs/. Recette détaillée ; la version condensée vit danspatterns.md→ « Écrire un test e2e », l'architecture danse2e-overview.md.
Checklist d'un nouveau test
- Choisir le fichier : un spec = un écran (
test.describepar écran). Si l'écran a déjà son spec (e2e/specs/), ajouter le test dedans ; sinon créere2e/specs/<ecran>.spec.ts. - Importer depuis les fixtures — jamais
@playwright/testdirectement :tsimport { test, expect } from '../support/fixtures' - Arranger AVANT
page.goto: mutation deworld, overridesmockBackend,seedAuth(). Après le premier chargement, le cache LRU in-page retient les GET publics — un override tardif exigepage.reload(). - Structurer en
test.step— un step par action utilisateur, pas de steps imbriqués (lint playwright). Titres de tests en anglais (convention existante), copy assertée en français. - Sélecteurs :
getByRole/ texte français d'abord (la copy fait partie du contrat) ;getByTestIdquand le sélecteur se couplerait au DOM Bootstrap. Testids kebab-case, posés dans les templates uniquement — si le testid manque, l'ajouter au composant fait partie du travail.- Radios
ValidatedField(civilité…) : les ids sont randomisés par instance (radio-<value>-<rand>, pour l'association label→input quand plusieurs formulaires passagers sont montés) — ne jamais cibler#radio-*. Sélectionner par label accessible, scopé au formulaire :form.getByRole('radio', { name: 'Mme', exact: true }).exact: trueobligatoire : le matchingnamepar défaut est un substring insensible à la casse, donc'M'matcherait aussi « Mme » (violation strict-mode). Attention, les libellés varient par formulaire (« M » sur le signupPersonalInfoForm, « M. » surTBPassengerInfoForm).
- Radios
- Vérifier le contrat, pas que l'UI : si le flow envoie quelque chose au backend, asserter le payload (voir plus bas).
- Avant de pousser :
npm run type-check:e2e, puisnpm run test:e2e -- --project=mobile-chrome --repeat-each=2(anti-flake), et mettre à joure2e-coverage.md.
Cas particulier — le wizard de réservation : tester la matrice complète
TRÈS IMPORTANT : tout changement au wizard (sauf s'il est explicitement limité à un cas d'usage précis) doit être couvert sur la matrice complète :
| Course d'un jour | Multi-day (catalogue) | Balnéaire (Seaside) | |
|---|---|---|---|
| Invité | ✔ à tester | ✔ à tester | ✔ à tester |
Connecté (seedAuth()) | ✔ à tester | ✔ à tester | ✔ à tester |
Les trois types de voyage empruntent des chemins différents du wizard : étapes skippées (date unique, pas de chambres, pas de sièges), tarification par tranche vs par chambre, pension/assurance présentes ou absentes — et invité vs connecté change le préremplissage (passager 1, facturation, contact) et la copy de confirmation. Un fix vérifié sur un seul flow peut casser silencieusement les autres. Le monde fournit un voyage par type (makeTravel / makeOneDayTravel / makeSeasideTravel, cf. e2e-world.md) — étendre les specs booking-wizard-*.spec.ts correspondants.
Squelette
import { test, expect } from '../support/fixtures'
import { makeTravel } from '../fixtures/travel'
test.describe('Mon écran', () => {
test('does the thing', async ({ page, world, mockBackend, seedAuth }) => {
// — arrange : tout AVANT goto —
world.travels[0] = makeTravel({ name: 'Voyage custom' })
await seedAuth() // omettre pour un test invité
await page.goto('/ma-route')
await test.step('the user does X', async () => {
await page.getByRole('button', { name: 'Réserver' }).click()
await expect(page.getByTestId('mon-testid')).toBeVisible()
})
})
})Recettes de customisation
Muter le monde (dataset différent)
world est frais par test — muter sans crainte, avant goto :
world.bookings = [makeBooking()] // client avec une résa
world.travels[0] = makeTravel({ bookingState: TravelBookingState.FULL }) // voyage complet
world.travels[1] = makeOneDayTravel({
occurrences: [makeOccurrence({ id: IDS.occColmar1, noVehiclePlan: true })],
})Les factories acceptent des overrides partiels à tous les niveaux ; les enums viennent de ../fixtures/enums.ts (jamais de deep-import ailleurs), les ids de ../fixtures/ids.ts, les dates de daysFromNow() (jamais en dur).
Overrider un endpoint (les enregistrements tardifs gagnent)
Ré-enregistrer suffit — pas besoin de désenregistrer le handler du monde :
// réponse d'erreur
mockBackend.onPost('/api/authentification', { message: 'bad credentials' }, { status: 401 })
mockBackend.onGet('/api/mobile/booking/payment-callback', { message: 'boom' }, { status: 500 })
// réponse métier différente
mockBackend.onGet(
'/api/mobile/booking/payment-callback',
makePaymentCallbackResponse({ success: false, transactionStatus: 'CANCELED' }),
)
// handler dynamique (accès à la requête)
mockBackend.onGet('/api/travels/:slug', ({ pathParams, json }) =>
json({ message: 'not found' }, 404),
)Signatures utiles : onGet/onPost/onPut/onDelete(pattern, bodyOuHandler, {status}?) ; patterns string avec segments :param (et :param? optionnel), anchorables à une origine (`${WEBSITE_ORIGIN}/api/...`), ou RegExp (matchée sur l'href complet, query incluse). Binaires : onRaw(method, pattern, { body, contentType, status? }).
Ajouter un NOUVEL endpoint (l'app vient d'en consommer un de plus)
Conséquence du fail-loud : dès que src/ appelle un endpoint inconnu, tous les tests qui le touchent échouent en listant MÉTHODE URL orpheline. La correction va dans e2e/support/world.ts (registerDefaultWorld), pas dans chaque spec — le monde doit répondre quelque chose de cohérent pour le happy path, les specs n'overrident que les variantes. Mettre à jour le tableau de e2e-world.md dans la foulée.
Session connectée vs invité
- Invité : ne rien faire (défaut).
- Connecté :
await seedAuth()avantgoto. Le customer seedé estworld.customer— le muter avantseedAuth()pour un profil différent. Ne pas fabriquer le localStorage à la main : les formats de persistance sont stricts (authJSON brut + JWT décodable,customersuperjson).
Asserter un contrat (payload envoyé au backend)
const init = await mockBackend.waitForCaptured('POST /api/mobile/booking/initialize')
const body = init.postDataJSON as { booking: Booking; customer: Customer }
expect(body.booking.general.occurrenceId).toBe(IDS.occRhin1)
// query params — chaque requête capturée expose `query: URLSearchParams`
const search = mockBackend.captured('GET /travels').find((r) => r.query.get('search') === 'rhin')
expect(search?.query.get('seaside')).toBe('false')captured('MÉTHODE /path')matche méthode + pathname exact (query ignorée, slashes finaux ignorés) et renvoie toutes les occurrences ;waitForCapturedpoll (5 s par défaut) et renvoie la dernière.- Pour un param émis parmi plusieurs appels au même endpoint, préférer
expect.poll(() => mockBackend.captured(...).some(...))— l'UI peut émettre plusieurs requêtes successives.
Simuler Saferpay (paiement)
Le flux réel : initialize → window.open(redirectUrl) → retour deep-link. Dans les tests :
const popupPromise = page.waitForEvent('popup') // AVANT le clic Payer
await page.getByRole('button', { name: /Payer/ }).click()
const popup = await popupPromise // le stub saferpay.e2e.test/pay
await expect(popup.getByTestId('saferpay-stub')).toBeVisible()
await popup.close()
// simuler le retour deep-link natif :
await page.goto('/booking/confirmation?bookingId=booking-new-1')Le popup est routé par le context (le mock le couvre) ; le monde répond déjà à initialize, payment-callback et GET /pay.
Assertions d'historique (back navigation, ADR 0009)
L'historique navigateur est le back-stack réel de l'app — il se teste directement : page.goBack() puis asserter l'URL/l'état (expect(page).toHaveURL(...), overlay fermé = param ?overlay= disparu). Pour une page terminale (router.replace), asserter qu'un back ne revient pas dessus (not.toHaveURL(/booking\/confirmation/)).
Anti-patterns
| Ne pas faire | Pourquoi |
|---|---|
import { test } from '@playwright/test' | Perd le mock auto + le fail-loud — le test parlerait au vrai réseau (et échouerait en DNS) |
Dates en dur (new Date('2026-08-01')) | Pourrissent face aux filtres « départs à venir » — daysFromNow() |
Deep-import d'enums hors fixtures/enums.ts | Le dist horizon-types casse au runtime Node (cf. DETTE_TECHNIQUE.md) |
Overrider un endpoint après page.goto sans reload() | Le cache LRU in-page sert l'ancienne réponse |
| Page objects / abstractions de navigation | Convention assumée : specs directs et lisibles seuls |
| Ids inventés dans un spec | Passer par IDS — la cohérence occurrence ↔ catalog ↔ sièges est ce qui fait marcher le wizard |
Attendre un timeout fixe (waitForTimeout) | waitForCaptured / expect.poll / auto-wait des locators |
Debug
npm run test:e2e -- --ui: mode interactif, timeline + DOM à chaque step.- Traces :
retain-on-failure—npx playwright show-trace test-results/<test>/trace.zip(en CI : artefacttest-results/). - Échec « Requests reached the mock backend without a registered handler » : c'est le teardown fail-loud. La liste jointe donne les
MÉTHODE URLorphelines → enregistrer l'endpoint dansworld.ts(nouveau endpoint app) ou corriger le pattern (typo). - Échec
waitForCaptured: le message liste toutes les requêtes vues (Seen: …) — vérifier méthode/chemin exact et que l'action UI a bien été déclenchée. - Flake local :
--repeat-each=2, et vérifier qu'aucun arrange ne se fait aprèsgoto.
Voir aussi
e2e-overview.md— architecture, fixtures, commandes, CI.e2e-world.md— dataset par défaut + catalogue des factories.e2e-coverage.md— à mettre à jour à chaque test ajouté.

