Guides

Tester les emails avec Bun, Vitest et Playwright

Utilisez des configurations de test, des assertions natives, des rapports avec données sensibles masquées et des pannes SMTP avec Bun test, Vitest et Playwright.

InboxTap fournit des configurations de test optionnelles et natives pour Bun test, Vitest et Playwright. Chaque configuration démarre les écouteurs SMTP et HTTP locaux sur des ports sélectionnés automatiquement, crée un transport Nodemailer prêt à l’emploi et garantit le nettoyage.

Installer les dépendances optionnelles

Installez uniquement l’adaptateur de l’outil d’exécution utilisé par votre projet. Nodemailer 9 est requis par toutes les configurations ; Vitest 4.1 et Playwright 1.61 sont des dépendances optionnelles de leurs sous-chemins respectifs.

bun add --dev inboxtap nodemailer
bun add --dev vitest
# or
bun add --dev @playwright/test

Les dépendances des configurations de test restent derrière des sous-chemins de paquet isolés. Importer inboxtap ou inboxtap/client ne nécessite ni Nodemailer, ni Vitest, ni Playwright.

Configuration de test partagée

Utilisez startInboxTapFixture() lorsqu’un outil d’exécution possède son propre modèle de cycle de vie. Les deux ports valent 0 par défaut : le système d’exploitation choisit donc des ports libres. Le démarrage vérifie le transport Nodemailer avant de rendre la main, nettoie les échecs partiels et close() peut être appelé plusieurs fois sans risque.

import { startInboxTapFixture } from "inboxtap/fixtures";

const inboxTap = await startInboxTapFixture();

try {
  const inbox = await inboxTap.createInbox({ alias: "signup" });
  await inboxTap.transport.sendMail({
    from: "app@local.test",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  await inbox.waitForMessage({ subject: /verify your account/i });
} finally {
  await inboxTap.close();
}

L’objet renvoyé expose aussi server, client et les informations de connexion smtp pour les applications qui doivent démarrer avec les ports sélectionnés.

Bun test

setupInboxTap() enregistre les fonctions asynchrones de configuration et de nettoyage de Bun. Bun n’injecte pas de contexte de test personnalisé : créez donc explicitement une nouvelle boîte dans chaque test.

import { expect, test } from "bun:test";
import { setupInboxTap } from "inboxtap/fixtures/bun";

const inboxTap = setupInboxTap();

test("captures one email", async () => {
  const inbox = await inboxTap.createInbox({ alias: "signup" });
  await inboxTap.transport.sendMail({
    from: "app@local.test",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  const message = await inbox.waitForMessage({ subject: /verify your account/i });
  expect(message.envelope.to).toContain(inbox.address);
});

Vitest

extendInboxTap() ajoute à un test de base Vitest un contexte inboxTap à portée fichier et un contexte inbox à portée test. Chaque test reçoit une nouvelle adresse tandis que le fichier réutilise un seul serveur et un transport vérifiés.

import { expect, test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";

const test = extendInboxTap(base);

test("captures one email", async ({ inboxTap, inbox }) => {
  await inboxTap.transport.sendMail({
    from: "app@local.test",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  const message = await inbox.waitForMessage({ subject: /verify your account/i });
  expect(message.envelope.to).toContain(inbox.address);
});

Playwright

L’adaptateur Playwright fournit un contexte inboxTap à portée processus et un contexte inbox à portée test. Chaque processus reçoit ses propres ports dynamiques et chaque test sa propre adresse destinataire.

import { expect, test as base } from "@playwright/test";
import { extendInboxTap } from "inboxtap/fixtures/playwright";

const test = extendInboxTap(base);

test("captures a verification link", async ({ inboxTap, inbox }) => {
  await inboxTap.transport.sendMail({
    from: "app@local.test",
    to: inbox.address,
    subject: "Verify your account",
    text: "Open https://app.local.test/verify?id=example",
  });

  const link = await inbox.waitForLink({ subject: /verify your account/i });
  expect(link).toContain("/verify");
});

Si l’application a besoin du port SMTP dynamique, démarrez-la dans un autre contexte à portée processus qui dépend de inboxTap, puis lisez inboxTap.smtp au lancement du processus. Le webServer de Playwright démarre avant les contextes de test : un webServer déjà démarré ne peut donc pas consommer un port sélectionné plus tard par le contexte InboxTap à portée processus.

Assertions natives des outils d’exécution

InboxTap conserve les implémentations des assertions dans le sous-chemin sans dépendance optionnelle inboxtap/matchers et publie des adaptateurs typés pour chaque outil d’exécution. Injectez l’expect de l’outil dans extendInboxTapExpect(). Bun et Vitest étendent cet objet sur place :

import { expect } from "vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";

extendInboxTapExpect(expect);

La configuration Bun suit le même modèle avec bun:test et inboxtap/matchers/bun. L’expect.extend() natif de Playwright renvoie un nouvel objet : exportez donc la valeur renvoyée avec votre contexte de test étendu.

import { expect as baseExpect, test as baseTest } from "@playwright/test";
import { extendInboxTap } from "inboxtap/fixtures/playwright";
import { extendInboxTapExpect } from "inboxtap/matchers/playwright";

export const test = extendInboxTap(baseTest);
export const expect = extendInboxTapExpect(baseExpect);

Les assertions s’utilisent avec la boîte propre au test et les messages capturés :

await expect(inbox).toHaveDeliveredOnce({
  subject: /verify your account/i,
  quietMs: 100,
});

const email = await inbox.waitForMessage({ subject: /verify your account/i });
expect(email).toHaveRecipient(inbox.address);
expect(email).toContainLink("/verify");
expect(email).toHaveUnsubscribeHeader({ oneClick: true });

toHaveDeliveredOnce() est une assertion sur un instantané : elle inspecte les messages déjà capturés et n’attend pas la première livraison. quietMs est une fenêtre d’observation facultative qui ne commence que si l’instantané initial contient exactement un message correspondant. Elle peut détecter une nouvelle tentative pendant cette fenêtre, mais ne prouve pas qu’aucune tentative plus tardive n’arrivera.

Utilisez directement inboxTapMatchers avec un autre expect compatible de style Jest, ou créez un nouvel ensemble via createInboxTapMatchers({ recorder }). Ces exports n’importent aucun outil d’exécution. Le collecteur reçoit des observations structurées sans contenu sensible ; les diagnostics des assertions omettent également les corps, les valeurs de destinataire, les liens, les motifs porteurs de jetons et les en-têtes bruts.

Écrire un rapport de test avec les données sensibles masquées

InboxTapReport, depuis inboxtap/reports, recueille à la fois les observations des assertions, les messages capturés et les assertions de l’application. Pour un flux de rapport Vitest non concurrent, étendez l’expect lié au test en cours :

import { test as base } from "vitest";
import { extendInboxTap } from "inboxtap/fixtures/vitest";
import { extendInboxTapExpect } from "inboxtap/matchers/vitest";
import { InboxTapReport } from "inboxtap/reports";

const test = extendInboxTap(base);

test("writes redacted evidence", async ({ expect, inboxTap, inbox }) => {
  const report = new InboxTapReport({ title: "Signup email" });
  extendInboxTapExpect(expect, { recorder: report });

  try {
    await inboxTap.transport.sendMail({
      from: "app@local.test",
      to: inbox.address,
      subject: "Verify your account",
      text: "Open https://app.local.test/verify/id-example?next=private",
    });
    await expect(inbox).toHaveDeliveredOnce({ subject: /verify/i });
    const email = await inbox.waitForMessage({ subject: /verify/i });

    report.addAssertion({
      name: "verification email exposes one link",
      passed: email.links.length === 1,
      messageId: email.id,
    });
  } finally {
    for (const email of await inbox.messages()) report.addMessage(email);
    await report.write("artifacts/signup-email.json");
    await report.write("artifacts/signup-email.html");
  }
});

L’écriture depuis finally conserve les preuves les plus récentes lorsqu’une assertion ou une assertion de l’application échoue.

Les mêmes entrées ordonnées produisent un JSON déterministe et versionné ou un HTML statique autonome. write() déduit le format de .json ou .html et crée les répertoires parents manquants. Le rapport exclut par défaut la source RFC brute, attribue des pseudonymes cohérents aux adresses e-mail, masque les surfaces secrètes courantes, échappe le HTML capturé et ne charge jamais de ressources distantes. La collecte s’arrête à 100 messages et 1 000 assertions ; chaque artefact rendu est limité à 10 Mio et consigne explicitement la troncature. Le comptage des octets omis distingue les valeurs exactes des limites inférieures mesurées.

La portée du collecteur suit l’expect étendu. Ne rattachez pas de manière répétée des collecteurs propres aux tests à un même expect Bun ou Vitest partagé pendant une exécution concurrente ; enregistrez explicitement les messages et les assertions de l’application, ou produisez délibérément un seul artefact pour la suite. Playwright peut créer un nouvel expect renvoyé pour chaque rapport. Le masquage reste une protection non exhaustive : vérifiez les artefacts avant de les partager. includeRaw: true est une option plus risquée, car elle conserve une copie de la source RFC brute dont les données sensibles ne sont masquées que de manière non exhaustive.

Isolation et nettoyage

Ne partagez le serveur qu’à la portée définie par l’adaptateur. Ne créez jamais un seul TestInbox global pour toute la suite : appelez createInbox() dans chaque test Bun, ou utilisez l’inbox injectée dans Vitest et Playwright. Des destinataires d’enveloppe uniques isolent les tests concurrents sans effacer les messages d’un autre test.

Tous les adaptateurs arrêtent le serveur et ferment le transport à la fin de leur portée native. Vous pouvez passer des options serveur à setupInboxTap(options) ou extendInboxTap(baseTest, options) si les valeurs par défaut doivent être adaptées ; les ports SMTP et API omis restent dynamiques.

Tester les chemins d’échec

Utilisez le contrôleur de pannes du serveur de la configuration de test pour tester les nouvelles tentatives de l’application à la vraie frontière SMTP. Ciblez chaque règle sur le destinataire d’enveloppe unique du test afin qu’une transaction concurrente ne puisse pas la consommer. Enregistrez la règle juste avant de déclencher l’application.

test("retries a transient SMTP failure", async ({ inboxTap, inbox }) => {
  const send = () =>
    inboxTap.transport.sendMail({
      from: "app@local.test",
      to: inbox.address,
      subject: "Verify your account",
      text: "Open https://app.local.test/verify?id=example",
    });

  inboxTap.server.faults.failNext({
    code: 451,
    to: inbox.address,
  });

  await expect(send()).rejects.toThrow();
  await expect(send()).resolves.toBeDefined();

  expect(await inbox.messages()).toHaveLength(1);
});

Une réponse 451 convient aux chemins de nouvelle tentative et d’attente progressive ; utilisez une réponse 550 pour le traitement des échecs permanents. delayNext() teste les délais d’expiration de l’application, et disconnectNext() la récupération après une session SMTP interrompue. Les tentatives échouées ou déconnectées ne produisent jamais de message capturé partiel.

Pour la concurrence, utilisez pauseNext() comme barrière explicite au lieu d’une temporisation. Attendez gate.waitUntilPaused() avant de lancer l’action concurrente et appelez gate.release(), idempotente, dans un bloc finally. Le délai d’expiration absolu de 60 secondes par défaut borne toujours une correspondance ou une libération manquée ; si une pause active expire, SMTP renvoie 451 et le message n’est pas capturé. Le nettoyage abandonne les barrières restantes ; évitez d’appeler reset() depuis un test concurrent, car il affecte les autres règles du serveur partagé.

Une seule règle de panne s’applique à une transaction. Utilisez times lorsque plusieurs tentatives consécutives doivent subir le même échec, ou enregistrez la règle suivante après avoir observé la tentative précédente. InboxTap contrôle la livraison SMTP ; la persistance, l’idempotence et la déduplication métier restent des assertions appartenant aux tests de l’application.

Choisir la bonne méthode

  • Utilisez waitForLink() pour les URL de vérification, de réinitialisation de mot de passe et de lien magique.
  • Utilisez waitForCode() pour les OTP numériques ; passez pattern pour un format non standard.
  • Utilisez waitForMatch() pour une clé d’API ou une autre valeur intégrée dans le corps.
  • Utilisez waitForMessage() quand l’assertion a besoin des en-têtes, des destinataires, du HTML ou de la source brute.
  • Utilisez messages() quand l’email est peut-être déjà arrivé et que vous devez inspecter l’ensemble courant.

Toutes les méthodes d’attente sont bornées. Réglez timeoutMs assez haut pour le parcours applicatif, mais gardez-le sous le délai d’expiration propre à l’outil d’exécution pour que les échecs remontent d’abord le contexte InboxTap.