Pourquoi Cypress a besoin d’une tâche pour ça
Un spec Cypress s’exécute à l’intérieur du navigateur, dans la même fenêtre que la page testée. C’est ce qui rend cy.get et cy.contains si directs, et c’est aussi pourquoi le spec ne peut pas simplement boucler sur une API HTTP pendant une minute : la file de commandes n’est pas un endroit pour une boucle while avec un sleep dedans, et une chaîne d’appels cy.request retentés est difficile à lire et plus difficile encore à arrêter.
Les trois façons habituelles de tester la moitié « e-mail » d’un flux prouvent chacune quelque chose de différent, et une seule prouve ce que vous avez réellement livré :
- Simuler le mailer
- Prouve que
send()a été appelé avec les bons arguments. Ne dit 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 conteneur 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 coût, c’est que le test doit attendre correctement, et dans Cypress, le bon endroit pour attendre est une tâche.
L’API que la tâche appelle, ce sont trois endpoints sans clé — la référence est courte. La version indépendante du runner de cette discipline se trouve dans tester un flux de vérification d’e-mail de bout en bout ; la version Playwright, avec une fixture au lieu d’une tâche, est dans le guide Playwright.
La tâche : une boucle d’interrogation côté Node
Tout ce qui doit attendre vit ici, dans setupNodeEvents. C’est du Node ordinaire : fetch, une échéance, une lecture par seconde, et une branche 429 qui patiente plutôt que d’échouer. Le spec n’en voit jamais rien.
import { defineConfig } from 'cypress';
const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
type Args = { address: string; subjectContains?: string; fromContains?: string; timeoutMs?: number };
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
taskTimeout: 90_000, // above the mail deadline below, always
setupNodeEvents(on) {
on('task', {
/** Poll a mailbox until a matching message arrives, or the deadline passes. */
async waitForMail({ address, subjectContains, fromContains, timeoutMs = 60_000 }: Args) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(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 ${address}`);
const { messages } = (await res.json()) as { messages: { id: string; from: string; subject: string }[] };
const hit = messages.find(m =>
(!subjectContains || m.subject.toLowerCase().includes(subjectContains.toLowerCase())) &&
(!fromContains || m.from.toLowerCase().includes(fromContains.toLowerCase())));
if (hit) {
const full = await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`);
if (!full.ok) throw new Error(`GET /message answered ${full.status}`);
return full.json(); // the whole message, both parts
}
await sleep(1000); // one read a second, never throttled
}
return null; // "not yet" is an answer, not an error
},
});
},
},
});Deux décisions dans ce fichier méritent d’être signalées. La tâche renvoie le message complet, pas le résumé, parce que la chose suivante que veut chaque spec est le corps, et un second appel de tâche pour ça serait du bruit. Et elle renvoie null à l’échéance plutôt que de lever une exception : « pas encore de message » est une réponse légitime qu’une tâche peut donner, et c’est la commande ci-dessous qui la transforme en échec avec un message utile.
Deux commandes personnalisées et deux extracteurs
Les commandes sont volontairement minces. freshAddress invente une boîte ; waitForMail appelle la tâche avec un timeout confortablement au-dessus de l’échéance et vérifie la réponse. Les extracteurs sont de simples fonctions, parce que c’est du simple traitement de chaînes, et une commande Cypress ne ferait que rendre leur test unitaire plus difficile.
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
type WaitOpts = { subjectContains?: string; fromContains?: string; timeoutMs?: number };
declare global {
namespace Cypress {
interface Chainable {
/** A mailbox nothing else in this run, or any previous run, is using. */
freshAddress(prefix?: string): Chainable<string>;
/** Block until a matching message arrives. Fails the test at the deadline. */
waitForMail(address: string, opts?: WaitOpts): Chainable<Message>;
}
}
}
Cypress.Commands.add('freshAddress', (prefix = 'cy') =>
cy.wrap(`${prefix}-${Math.random().toString(36).slice(2, 10)}@grabmail.io`, { log: false }));
Cypress.Commands.add('waitForMail', (address, opts = {}) =>
cy.task<Message | null>('waitForMail', { address, ...opts }, { timeout: (opts.timeoutMs ?? 60_000) + 10_000 })
.then(m => {
expect(m, `a message for ${address}`).not.to.be.null;
return cy.wrap(m as Message, { log: false });
}));
/** The whole body, both parts, with the entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/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 hit = bodyOf(m).match(new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`));
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Notez le timeout propre à la commande : c’est l’échéance de la tâche plus dix secondes, afin que la tâche ait toujours le temps de donner sa réponse. Sans cela, le timeout de tâche par défaut de Cypress, de soixante secondes, entre en course avec l’échéance de courrier de soixante secondes et gagne de quelques millisecondes, et l’échec accuse la tâche.
Trois specs, de bout en bout
Avec la tâche et les commandes en place, chaque spec se lit comme la fonctionnalité qu’il exerce. L’attente et l’analyse sont ailleurs, ce qui est précisément le but de les y avoir mises.
Inscription avec un code de confirmation
import { codeFrom } from '../support/commands';
describe('sign-up', () => {
it('confirms the address with the emailed code', () => {
cy.freshAddress().then(address => {
cy.visit('/signup');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('correct-horse-battery-staple');
cy.contains('button', 'Create account').click();
cy.contains('Check your inbox').should('be.visible');
cy.waitForMail(address, { subjectContains: 'confirm' }).then(message => {
cy.get('input[name="code"]').type(codeFrom(message));
cy.contains('button', 'Confirm').click();
cy.contains('h1', 'Welcome').should('be.visible');
});
});
});
});Une connexion qui demande un code à usage unique envoyé par e-mail
L’utilisateur doit d’abord exister, 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 — atteint avec cy.request, pas via le navigateur.
import { codeFrom } from '../support/commands';
describe('login with an emailed one-time code', () => {
it('asks for the code and accepts it', () => {
cy.freshAddress().then(address => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
cy.request('POST', '/internal/test/users', { email: address, password: 'hunter2hunter2', otpByEmail: true });
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('hunter2hunter2');
cy.contains('button', 'Log in').click();
cy.contains('Enter the code we emailed you').should('be.visible');
cy.waitForMail(address, { subjectContains: 'code' }).then(message => {
cy.get('input[name="otp"]').type(codeFrom(message, /code is\s*([0-9]{6})/i));
cy.contains('button', 'Continue').click();
cy.url().should('include', '/dashboard');
});
});
});
});Une réinitialisation de mot de passe, puis une connexion avec le nouveau mot de passe
Le lien de réinitialisation est suivi avec un simple cy.visit lorsqu’il pointe vers la même origine que baseUrl. Si votre application envoie les utilisateurs vers une autre origine pour la page de réinitialisation — un sous-domaine d’authentification, par exemple — enveloppez les étapes sur cette page dans cy.origin() ; l’extraction du lien reste inchangée.
import { linkFrom } from '../support/commands';
describe('password reset', () => {
it('changes the password through the emailed link', () => {
cy.freshAddress().then(address => {
cy.request('POST', '/internal/test/users', { email: address, password: 'old-password-1' });
cy.visit('/forgot-password');
cy.get('input[name="email"]').type(address);
cy.contains('button', 'Send reset link').click();
cy.waitForMail(address, { subjectContains: 'reset' }).then(message => {
cy.visit(linkFrom(message, '/reset/')); // same origin as baseUrl: a plain visit
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Change password').click();
});
cy.visit('/login');
cy.get('input[name="email"]').type(address);
cy.get('input[name="password"]').type('new-password-2');
cy.contains('button', 'Log in').click();
cy.url().should('include', '/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ôme | Cause | Correction |
|---|---|---|
| Passe en local, échoue en CI | Le runner ne peut pas atteindre l’Internet public, ou le trafic sortant est filtré. | Autorisez grabmail.io en HTTPS depuis le côté Node. Rien d’autre — aucun port SMTP, aucun trafic entrant. |
| « cy.task timed out » sans un mot sur le courrier | taskTimeout (60 s par défaut) est en dessous de l’échéance du courrier. | Réglez taskTimeout au-dessus de l’échéance dans la configuration, et passez le timeout par appel que la commande calcule déjà. |
| Échoue la première fois, passe en réessayant | Votre 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 rafales | Plusieurs specs interrogeant une même adresse, ou le runner dépassant 1200 requêtes par minute. | Une adresse par spec. Le plafond côté client est de vingt boîtes interrogées une fois par seconde. |
| Build vert, fonctionnalité cassée | Une adresse réutilisée a servi un ancien message. | freshAddress dans chaque corps de test. C’est celle-ci qui compte. |
| Fonctionne pendant une semaine, puis plus jamais | Une fixture a mis en cache un identifiant de message ; tout ici est supprimé au bout de 5 jours. | Les specs 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 specs, quelque chose a été mal compris. Un workflow GitHub Actions qui exécute une suite comme celle-ci, la question du trafic sortant étant réglée, se trouve 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 spec — 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 tâche 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 spec, inventée dans le corps du test — jamais une constante.
- L’attente dans une tâche avec une échéance en temps réel ;
nullà l’échéance, jamaisundefined. taskTimeoutet le timeout de la commande, tous deux au-dessus de l’échéance du courrier.429géré en patientant la durée deRetry-After, pas en échouant.- Le code ou le lien mis en correspondance avec votre propre formulation, pas un motif nu.
- 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. Les règles d’extraction seules, pour n’importe quel runner, sont dans les codes OTP dans les tests automatisés.
Questions
Pourrais-je utiliser cy.request dans une boucle plutôt qu’une tâche ?
C’est possible : cy.request s’exécute lui aussi côté Node, donc il n’est pas soumis au CORS, et une fonction récursive qui relance la requête jusqu’à une correspondance ou une échéance fonctionne. C’est simplement plus difficile à lire et plus difficile à arrêter qu’une tâche avec une boucle while dedans, et la tâche garde le spec libre de toute logique de nouvelle tentative.
Ai-je besoin d’une clé d’API ou d’une variable d’environnement Cypress ?
Non. Les domaines publics ne demandent ni clé, ni compte, ni en-tête, donc il n’y a rien à mettre dans cypress.env.json ou dans les secrets de la CI. Seul le pool payant de domaines tenus à l’écart des listes noires de mail jetable utilise un token bearer, et c’est un produit séparé.
Est-ce que ça fonctionne avec les nouvelles tentatives de test et la parallélisation de Cypress ?
Oui, précisément parce que l’adresse est inventée à l’intérieur du corps du test : chaque nouvelle tentative et chaque machine parallèle obtient sa propre boîte. Le plafond par client de 1200 requêtes par minute correspond à vingt boîtes interrogées une fois par seconde, dont une exécution Cypress ne s’approche jamais.
Et si l’e-mail arrive avant que la tâche 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 spec 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 depuis la tâche, 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.


