Skip to content

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 dans patterns.md → « Écrire un test e2e », l'architecture dans e2e-overview.md.

Checklist d'un nouveau test

  1. Choisir le fichier : un spec = un écran (test.describe par écran). Si l'écran a déjà son spec (e2e/specs/), ajouter le test dedans ; sinon créer e2e/specs/<ecran>.spec.ts.
  2. Importer depuis les fixtures — jamais @playwright/test directement :
    ts
    import { test, expect } from '../support/fixtures'
  3. Arranger AVANT page.goto : mutation de world, overrides mockBackend, seedAuth(). Après le premier chargement, le cache LRU in-page retient les GET publics — un override tardif exige page.reload().
  4. 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.
  5. Sélecteurs : getByRole / texte français d'abord (la copy fait partie du contrat) ; getByTestId quand 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: true obligatoire : le matching name par 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 signup PersonalInfoForm, « M. » sur TBPassengerInfoForm).
  6. Vérifier le contrat, pas que l'UI : si le flow envoie quelque chose au backend, asserter le payload (voir plus bas).
  7. Avant de pousser : npm run type-check:e2e, puis npm run test:e2e -- --project=mobile-chrome --repeat-each=2 (anti-flake), et mettre à jour e2e-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 jourMulti-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

ts
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 :

ts
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 :

ts
// 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() avant goto. Le customer seedé est world.customer — le muter avant seedAuth() pour un profil différent. Ne pas fabriquer le localStorage à la main : les formats de persistance sont stricts (auth JSON brut + JWT décodable, customer superjson).

Asserter un contrat (payload envoyé au backend)

ts
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 ; waitForCaptured poll (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 : initializewindow.open(redirectUrl) → retour deep-link. Dans les tests :

ts
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 fairePourquoi
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.tsLe 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 navigationConvention assumée : specs directs et lisibles seuls
Ids inventés dans un specPasser 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-failurenpx playwright show-trace test-results/<test>/trace.zip (en CI : artefact test-results/).
  • Échec « Requests reached the mock backend without a registered handler » : c'est le teardown fail-loud. La liste jointe donne les MÉTHODE URL orphelines → enregistrer l'endpoint dans world.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ès goto.

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é.

Contributors

No contributors

Changelog

No recent changes