Guides / Cypress

Guide

Cypress OTP testing without a mail server

Two custom commands and the verification half of your signup flow stops being untested.

Everything here runs against a disposable inbox created on demand over HTTP. No account, no API key, no SMTP container in docker-compose and no secret to configure on the runner.

The problem with the alternatives

A one-time code is dynamic, short-lived and arrives out of band. Your spec cannot know it in advance, so it has to go and read it. The usual routes all cost something:

RouteCost
Stub the mailerProves a function was called. Says nothing about the template, the code inside it or whether mail is actually leaving.
Self-hosted MailHogReal SMTP, but the suite now depends on infrastructure and CI needs a service container.
A shared Gmail with gmail-testerOAuth credentials in CI, a refresh token that expires, and collisions when specs run at once.
A fixed cy.wait(15000)Slow when the mail is fast, flaky when it is slow. Both at the same time.

Two commands

Both use cy.request, so the whole flow stays in the command queue. There is no cy.task here, which also sidesteps the rule that a task may never resolve to undefined.

cypress/support/commands.js

const MAIL_API = 'https://dev-mail.com';
const WAIT = 60; // server-side long-poll ceiling, in seconds

Cypress.Commands.add('createInbox', () => {
  return cy
    .request('POST', `${MAIL_API}/v1/inboxes`)
    .its('body')
    .then(({ address, token }) => ({ address, token }));
});

Cypress.Commands.add('waitForOtp', ({ address, token }, since = 0) => {
  const url =
    `${MAIL_API}/v1/inboxes/${encodeURIComponent(address)}/code` +
    `?wait=${WAIT}&since=${since}`;

  return cy
    .request({
      url,
      headers: { Authorization: `Bearer ${token}` },
      timeout: (WAIT + 10) * 1000, // must outlast the long poll
    })
    .its('body')
    .then((body) => {
      if (!body.found) {
        throw new Error(`no verification code reached ${address} within ${WAIT}s`);
      }
      return body.code;
    });
});

The timeout has to exceed the long-poll window. Cypress defaults cy.request to 30 seconds, which would abort a 60-second wait while the server is still holding the connection open on purpose.

The spec

cypress/e2e/signup.cy.js

describe('signup', () => {
  it('confirms a new account with the emailed code', () => {
    cy.createInbox().then((inbox) => {
      cy.visit('/signup');
      cy.get('[name="email"]').type(inbox.address);
      cy.get('[type="submit"]').click();

      cy.waitForOtp(inbox).then((otp) => {
        cy.get('[name="otp"]').type(otp);
        cy.contains('button', 'Verify').click();
        cy.url().should('include', '/dashboard');
      });
    });
  });
});

Each spec creates its own address, so cypress run --parallel works with no shared state and no cleanup step. An unused inbox expires by itself after 6 hours.

Asking one inbox for a second code

Resend flows, re-verification after an email change, or a worker that reuses one inbox to stay inside the rate limit all hit the same trap: the inbox still holds the first code, and the endpoint returns it straight away instead of waiting for the new mail. Pass since to draw a line in time.

it('sends a fresh code when the user asks to resend', () => {
  cy.createInbox().then((inbox) => {
    cy.visit('/signup');
    cy.get('[name="email"]').type(inbox.address);
    cy.get('[type="submit"]').click();
    cy.waitForOtp(inbox).as('first');

    // Take the cutoff BEFORE triggering the resend, never after:
    // a fast sender can beat a timestamp you read too late.
    const since = Math.floor(Date.now() / 1000);
    cy.contains('button', 'Resend').click();

    cy.waitForOtp(inbox, since).then((second) => {
      cy.get('@first').should('not.eq', second);
    });
  });
});

When no code arrives

Read the inbox instead of asking for a code. Listing is idempotent, so it never consumes what another assertion is waiting on:

cy.request({
  url: `https://dev-mail.com/v1/inboxes/${encodeURIComponent(inbox.address)}/messages`,
  headers: { Authorization: `Bearer ${inbox.token}` },
}).then(({ body }) => {
  cy.log(JSON.stringify(body.messages.map((m) => m.subject)));
});

If the message is there but found was false, the extractor did not recognise the format. It looks for digits near words like verification, code, OTP and 验证码. For an unusual template, read body_text from /messages and apply your own regex — something tight like /\b(\d{6})\b/ rather than a loose digit scan.

Limits worth knowing before you commit

Rate60 requests per minute and 20 new inboxes per hour, per IP. Over either returns 429 with Retry-After. Parallel CI machines behind one NAT share that budget.
SendingReceive only.
AttachmentsNot stored. Subject, HTML body and plain text only.
Durability6 hours of inactivity, then the address is recycled. Reading resets the clock.
BlocklistsIf your product rejects disposable domains at signup, that is your product working correctly. Test against a domain you control instead.

Next