Ouvrir une boîte

Tests et CI

Tests email Playwright : lire un code depuis une vraie boîte

Un test Playwright peut remplir un formulaire d’inscription en deux secondes, puis n’a plus aucune idée de ce qui se passe ensuite, parce que l’étape suivante est un e-mail. Voici la fixture qui le lit — un vrai message, depuis une vraie boîte, sans clé d’API — et les trois habitudes qui empêchent le test de devenir instable.

  • Intermédiaire
  • 20 min de lecture
Une fenêtre de navigateur grise avec une flèche de curseur bleue, une enveloppe bleue qui glisse dans une fente sur son côté, et un chronomètre gris devant

Où un test Playwright s’arrête habituellement

La plupart des tests d’inscription s’arrêtent à la phrase « consultez votre boîte ». Le formulaire a été rempli, le bouton cliqué, la page a dit ce qu’il fallait — et tout ce qui se passe après cette phrase est supposé acquis. Que le courrier soit parti, que le code qu’il contient soit celui attendu par le serveur, que le lien de confirmation ouvre une page qui fonctionne : tout cela est laissé à la production.

On laisse cela de côté parce que l’étape suivante est asynchrone et se déroule hors du navigateur, et Playwright n’a rien sur quoi cliquer. Les trois façons habituelles de contourner ça prouvent chacune quelque chose de différent :

Simuler le mailer
Prouve que votre code a appelé send(). Ne prouve rien sur le modèle, le lien, ou le fournisseur qui a rejeté le message.
Un récepteur SMTP local (Mailpit, MailHog, smtp4dev)
Prouve qu’un message bien formé est sorti de l’application. Un service de plus en CI, et rien de ce qui n’arrive que sur l’Internet public — une vraie recherche MX, un vrai fournisseur, un vrai destinataire — ne se produit ici.
Une vraie boîte jetable
Prouve que le message est sorti de l’application, a traversé Internet, a été accepté par un vrai serveur de messagerie, et porte un code qui fonctionne. Le seul coût, c’est que le test doit attendre correctement — ce qui fait l’objet de tout ce guide.

L’API derrière tout ça, ce sont trois endpoints sans clé, documentés dans la référence. Si vous voulez la discipline générale avant les spécificités de Playwright, tester un flux de vérification d’e-mail de bout en bout la couvre pour n’importe quel runner ; ceci est la version Playwright, avec la fixture qui rend ça agréable.

Une fixture qui donne à chaque test sa propre boîte

Le test.extend de Playwright est l’endroit adapté pour ça : une boîte devient quelque chose qu’un test demande par son nom, comme page, et l’adresse est inventée à chaque fois. Rien n’a besoin d’être créé côté serveur — une boîte existe dès que le courrier l’atteint — donc la fixture est une classe contenant une adresse aléatoire et trois petites méthodes.

tests/fixtures.ts
import { test as base, expect } from '@playwright/test';

const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';

export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
  id: string; from: string; to: string; subject: string; date: string;
  text: string | null; html: string | null;
};

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export class Inbox {
  readonly address: string;

  /** A mailbox nothing else in this run, or any previous run, is using. */
  constructor(prefix = 'e2e') {
    this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
  }

  /** Block until a matching message arrives, or the deadline passes. */
  async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
    const deadline = Date.now() + (opts.timeoutMs ?? 60_000);

    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);

      if (res.status === 429) {                       // slow down, do not fail
        await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
        continue;
      }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);

      const { messages } = (await res.json()) as { messages: Summary[] };
      const hit = messages.find(m =>
        (!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
        (!opts.fromContains    || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
      if (hit) return this.read(hit.id);

      await sleep(1000);                              // one read a second, never throttled
    }
    throw new Error(`no message for ${this.address} within the deadline`);
  }

  async read(id: string): Promise<Message> {
    const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
    if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
    return res.json() as Promise<Message>;
  }

  /** Optional: everything expires on its own after a few days. Idempotent. */
  async delete(id: string): Promise<void> {
    await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
  }
}

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    await use(new Inbox());
  },
});

export { expect };

Deux choses dans ce fichier sont délibérées. L’adresse est aléatoire par test, pas par fichier ni par exécution, afin que les workers parallèles ne puissent jamais lire le courrier les uns des autres. Et waitFor renvoie le message complet plutôt que le résumé — en pratique, vous voulez toujours le corps juste après, et un appel de moins à chaque test, ça finit par compter.

Attendre le message sans sleep

Le courrier n’est pas synchrone. Il arrive normalement en deux ou trois secondes et met parfois vingt secondes, et la façon dont le test attend détermine si la suite peut être digne de confiance. Les règles sont courtes :

  • Une échéance, pas un nombre de tentatives. for (let i = 0; i < 30; i++), ce sont trente tentatives à la vitesse à laquelle la boucle tourne — plus court si l’API accélère, plus long si votre expéditeur ralentit. Une échéance en temps réel signifie la même chose sur chaque machine.
  • Une lecture par seconde. C’est le rythme documenté, et il n’est jamais limité en deçà. Plus vite est refusé avec un 429 et un en-tête Retry-After, et interroger plus vite ne ferait pas arriver le courrier plus tôt.
  • Pas de waitForTimeout. Un sleep fixe est soit trop court les jours lents, soit trop long tous les autres jours. La boucle s’arrête au moment où le message existe.
  • Filtrez ; ne prenez pas aveuglément le message le plus récent. Passez subjectContains ou fromContains. Quand un flux envoie deux messages — un message de bienvenue et un code — le plus récent n’est pas toujours celui que vous voulez.

Les codes de statut que la boucle rencontrera, et ce qu’elle doit faire avec chacun :

CodeSignifieCe que fait la boucle
200La boîte a été lue. count peut valoir 0 — une boîte vide n’est jamais un 404.Cherchez une correspondance ; s’il n’y en a pas, patientez une seconde et recommencez.
400L’adresse est malformée.Levez une exception. Retenter ne corrige pas une faute de frappe.
404Le domaine n’est pas hébergé ici.Levez une exception, et vérifiez l’enregistrement MX si c’est votre propre domaine.
429Plus d’une lecture par seconde pour cette adresse, ou plus de 1200 requêtes par minute depuis ce runner.Patientez le nombre de secondes indiqué par Retry-After, puis continuez. Ne faites jamais échouer le test sur un 429.

Extraire le code, ou le lien, du message

Le message revient avec les deux parties, et celle à analyser dépend de ce que votre application envoie :

text
La partie texte brut. Analysez celle-ci quand elle existe — pas de balisage, et un code à six chiffres est un code à six chiffres.
html
La partie HTML, ou null quand l’expéditeur n’a envoyé que du texte. Les liens de confirmation ne vivent souvent que là, dans un <a href>, avec & écrit &amp;.
tests/extract.ts
import type { Message } from './fixtures';

/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&amp;/g, '&');

/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
  const hit = bodyOf(m).match(pattern);
  if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
  return hit[1];
}

/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
  const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
  const hit = bodyOf(m).match(re);
  if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
  return hit[0];
}

Le motif est ancré sur le libellé de votre propre modèle, volontairement. [0-9]{6} seul correspond joyeusement à une année, un prix ou un numéro de commande qui se trouve apparaître en premier ; code is ([0-9]{6}) ne correspond qu’à votre code et rien d’autre — et le jour où quelqu’un reformule l’e-mail, le test échoue et vous le signale, au lieu de réussir sur le mauvais nombre.

Les liens sont mis en correspondance sur un fragment de chemin que vous connaissez — /confirm/, /reset/ — plutôt que sur « la première URL », parce qu’un e-mail transactionnel en porte généralement cinq : le logo, la désinscription, le centre d’aide, le badge de l’app-store, et celui que vous voulez.

Trois flux, de bout en bout

Avec la fixture et les extracteurs en place, chaque test se lit comme la fonctionnalité qu’il exerce. L’attente, l’interrogation et l’analyse sont ailleurs, ce qui est précisément la raison de les y avoir mises.

Inscription avec un code de confirmation

tests/signup.spec.ts
import { test, expect } from './fixtures';
import { codeFrom } from './extract';

test('a new account confirms its email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();

  const message = await inbox.waitFor({ subjectContains: 'confirm' });

  await page.getByLabel('Confirmation code').fill(codeFrom(message));
  await page.getByRole('button', { name: 'Confirm' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Un lien magique qui connecte l’utilisateur

Rien à saisir : le test visite le lien que porte le message, et vérifie où il atterrit.

tests/magic-link.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a magic link signs the user in', async ({ page, inbox }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'sign in' });
  await page.goto(linkFrom(message, '/auth/magic/'));

  await expect(page).toHaveURL(/\/dashboard/);
});

Une réinitialisation de mot de passe, puis une connexion avec le nouveau mot de passe

Le test de réinitialisation a besoin d’un utilisateur qui existe déjà, ce qui relève du point d’entrée de test propre à votre application — un endpoint interne, une fixture de base de données, une CLI — pas du navigateur. Ensuite, le flux suit la même forme que les autres : demander, attendre, suivre, vérifier.

tests/password-reset.spec.ts
import { test, expect } from './fixtures';
import { linkFrom } from './extract';

test('a password reset link changes the password', async ({ page, inbox, request }) => {
  // Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
  await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const message = await inbox.waitFor({ subjectContains: 'reset' });
  await page.goto(linkFrom(message, '/reset/'));
  await page.getByLabel('New password').fill('new-password-2');
  await page.getByRole('button', { name: 'Change password' }).click();

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('new-password-2');
  await page.getByRole('button', { name: 'Log in' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Le faire survivre à la CI

Tout ce qui précède fonctionne sur un ordinateur portable. Voici ce qui ne casse qu’une fois que ça tourne vingt fois par jour sur la machine de quelqu’un d’autre.

SymptômeCauseCorrection
Passe en local, échoue en CILe runner ne peut pas atteindre l’Internet public, ou le trafic sortant est filtré.Autorisez grabmail.io en HTTPS. Rien d’autre — aucun port SMTP, aucun trafic entrant.
Échoue la première fois, passe en réessayantVotre expéditeur met le courrier en file d’attente, et l’échéance est plus courte que cette file.Relevez l’échéance avant de toucher à autre chose. Soixante secondes est un plafond raisonnable pour un e-mail transactionnel.
Des 429 par rafalesPlusieurs tests interrogeant une même adresse, ou tout le runner dépassant 1200 requêtes par minute.Une adresse par test — c’est ce que fait la fixture. Le plafond côté client est de vingt boîtes interrogées une fois par seconde.
Build vert, fonctionnalité casséeUne adresse réutilisée a servi un ancien message.Une adresse aléatoire par test. C’est celle-ci qui compte.
Instable seulement avec plusieurs workersDeux tests partageant une boîte, ou une vérification sur quel message est le plus récent.Une adresse fraîche par test et un filtre subjectContains ; jamais le message le plus récent à l’aveugle.
Fonctionne pendant une semaine, puis plus jamaisUne fixture qui a mis en cache un identifiant de message ; tout ici est supprimé au bout de 5 jours.Les tests doivent déclencher leur propre courrier à chaque exécution. Rien ne survit 5 jours.

Il n’y a aucun secret à stocker. Les domaines publics ne demandent ni clé, ni compte, ni en-tête — si votre pipeline a besoin d’un identifiant pour exécuter ces tests, quelque chose a été mal compris. Le seul réglage qui mérite d’être noté, c’est le timeout, parce que c’est la seule chose que les valeurs par défaut de Playwright règlent mal pour un test qui attend du courrier :

playwright.config.ts
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
  timeout: 120_000,
  expect: { timeout: 10_000 },
  fullyParallel: true,          // safe: every test has its own inbox
});

Un workflow GitHub Actions qui exécute cette suite, avec l’échéance et le trafic sortant réglés, est détaillé dans le guide CI.

Si votre application refuse les domaines jetables

Certains formulaires d’inscription vérifient l’adresse par rapport aux listes publiques de domaines jetables et refusent grabmail.io à vue. C’est une fonctionnalité de votre application, pas un défaut du test — et la solution n’est pas d’affaiblir la vérification pour l’environnement de test. Pointez plutôt un domaine qui vous appartient vers ce service : un enregistrement MX, aucun compte, et chaque adresse sur ce domaine devient une boîte que la même fixture peut lire en changeant une seule constante.

Transformer un domaine en boîte catch-all est la configuration ; des comptes de test illimités sur un seul domaine montre à quoi ça ressemble dans une suite de tests.

Avant de considérer que c’est terminé

  • Une adresse différente pour chaque test, depuis la fixture — jamais une constante.
  • Une échéance en temps réel, et un échec qui nomme l’adresse sur laquelle il attendait.
  • 429 géré en patientant la durée de Retry-After, pas en échouant.
  • Le code ou le lien mis en correspondance avec votre propre formulation, pas un motif nu.
  • Le timeout du test confortablement au-dessus de l’échéance du courrier.
  • Un filtre sur le sujet ou l’expéditeur, pour que le bon message l’emporte quand deux arrivent.
  • Aucune vérification sur la vitesse à laquelle le courrier est arrivé — seulement sur le fait qu’il soit arrivé.

C’est toute la discipline. Tout le reste, dans le test du courrier avec Playwright, revient à tester n’importe quoi d’autre d’asynchrone. Le même utilitaire décliné en commandes Cypress se trouve dans le guide Cypress ; les règles d’extraction seules, pour n’importe quel runner, sont dans les codes OTP dans les tests automatisés.

Questions

Ai-je besoin d’une clé d’API pour lire la boîte depuis Playwright ?

Non. Les domaines publics ne demandent ni clé, ni compte, ni en-tête. Seul le pool payant de domaines qui reste à l’écart des listes noires de mail jetable a besoin d’un en-tête Authorization: Bearer, et c’est un produit séparé.

Les tests peuvent-ils s’exécuter dans des workers parallèles ?

Oui, et c’est tout l’intérêt d’une adresse aléatoire par test : deux workers ne peuvent jamais lire le courrier l’un de l’autre. Le plafond par client est de 1200 requêtes par minute, soit vingt boîtes interrogées une fois par seconde — largement suffisant pour une suite, et de toute façon la fixture n’interroge jamais plus vite qu’une fois par seconde.

Devrais-je utiliser la fixture request de Playwright plutôt que fetch ?

Les deux fonctionnent. fetch est utilisé ici parce que l’utilitaire s’exécute alors sans changement dans un simple script Node, un global setup, ou un autre runner. Le request de Playwright ajoute une traçabilité des appels, ce qui vaut la peine si vous voulez que l’interrogation apparaisse dans le trace viewer.

Et si l’e-mail arrive avant que le test ne commence à interroger ?

Rien ne change. La première interrogation le récupère. Une boîte conserve ce qui arrive pendant 5 jours, que quelqu’un la lise ou non, donc un message qui atterrit pendant le clic est simplement là à la requête suivante.

La boîte est-elle privée pendant que le test l’utilise ?

Non. Quiconque connaît l’adresse peut la lire, sur un domaine public comme sur le vôtre. Pour une adresse aléatoire qui existe pendant onze secondes et contient un code jetable, c’est sans importance ; pour un environnement de staging qui envoie du vrai courrier client, c’est rédhibitoire — n’en pointez pas un ici.

Comment nettoyer après coup ?

Optionnellement, avec un DELETE sur le message, qui est idempotent. Tout expire de toute façon au bout de 5 jours, donc une exécution qui saute le nettoyage ne coûte rien — supprimer rend seulement le prochain échec plus facile à lire.

Essayez-le pendant que c'est encore frais

Une adresse s'obtient en un clic, sans compte et sans carte. Tout ce que contient ce guide fonctionne avec elle, immédiatement.

Bon retour

Vos boîtes et vos domaines, au même endroit.