otpbox

OTPBox / Guides / Cypress

Cypress

Test email verification with Cypress

Cypress specs run inside the browser, but an HTTP client that mints inboxes and polls for mail belongs in Node — so the pattern here is a cy.task() that wraps otpbox-sdk, called from an otherwise ordinary spec. No mocked email provider, no shared inbox that two parallel spec files fight over.

Status: a dedicated cypress-plugin/otpbox-cypress package (a ready-made cy.task/command wrapper) is being built but isn't published to npm yet. This guide uses otpbox-sdk directly, which is on npm today and is all a cy.task needs.

Why a task, not cy.request()

cy.request() can call the OTPBox REST API directly and would work for a one-shot inbox creation. But waiting for an email means polling, and a cy.task() that runs in Node lets you use otpbox-sdk's waitForOtp() as-is instead of hand-rolling a retry loop in the browser-side spec. It also keeps your API key out of the browser context entirely.

1. Install the SDK

npm install otpbox-sdk

2. Register tasks in cypress.config.ts

import { defineConfig } from 'cypress';
import { OTPBox } from 'otpbox-sdk';

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      const client = new OTPBox({ apiKey: process.env.OTPBOX_KEY! });
      let currentInboxId: string | null = null;

      on('task', {
        async otpboxCreateInbox() {
          const inbox = await client.createInbox();
          currentInboxId = inbox.id;
          return inbox; // { id, address, domain, expiresAt }
        },
        async otpboxWaitForOtp(inboxId: string) {
          return client.waitForOtp(inboxId, { timeoutMs: 20_000 });
        },
        async otpboxDeleteInbox(inboxId: string) {
          await client.deleteInbox(inboxId);
          return null;
        },
      });
    },
  },
});

Set OTPBOX_KEY in your shell, CI secret store, or a cypress.env.json that's gitignored — never commit it. No key? otpbox-sdk has no auto-mint helper of its own; mint one with curl -X POST https://otpbox.io/api/v1/keys/free (see Get a key) and export it, or use an organization key from the dashboard for CI.

3. Use the tasks in a spec

describe('sign up', () => {
  it('verifies with a real OTP', () => {
    cy.task('otpboxCreateInbox').then((inbox: any) => {
      cy.wrap(inbox.id).as('inboxId');

      cy.visit('https://your-app.example.com/signup');
      cy.get('[name="email"]').type(inbox.address);
      cy.get('button[type="submit"]').click();

      // Blocks Node-side until the SDK sees a message with an extracted code
      cy.task('otpboxWaitForOtp', inbox.id, { timeout: 25000 }).then((code) => {
        expect(code).to.be.a('string');
        cy.get('[name="otp"]').type(code as string);
        cy.get('button[type="submit"]').click();
        cy.contains('Welcome').should('be.visible');
      });
    });
  });

  afterEach(function () {
    if (this.inboxId) cy.task('otpboxDeleteInbox', this.inboxId);
  });
});

Two Cypress specifics worth calling out: pass a per-task timeout option to cy.task() that's longer than the SDK's own timeoutMs (Cypress's default task timeout is 60 seconds, but it's worth being explicit), and use this.inboxId/cy.wrap().as() rather than a module-level variable, since Cypress commands are asynchronous and queued rather than awaited directly.

Verifying via a link instead of a code

Some signup flows email a confirmation link instead of a 6-digit code. Add one more task alongside the others, then visit the URL it returns:

// cypress.config.ts, inside setupNodeEvents
on('task', {
  // ...otpboxCreateInbox, otpboxDeleteInbox as above...
  async otpboxWaitForLink(inboxId: string) {
    const message = await client.waitForEmail(inboxId, { timeoutMs: 20_000 });
    if (!message) return null;
    const full = await client.getMessage(message.id);
    return full.link?.url ?? null;
  },
});
cy.task('otpboxWaitForLink', inbox.id).then((url: any) => {
  expect(url).to.be.a('string');
  cy.visit(url);
  cy.contains('Verified').should('be.visible');
});

Wrapping tasks in a custom command

Once you've written the same cy.task('otpboxCreateInbox') / cy.task('otpboxWaitForOtp', ...) pair in more than one spec, it's worth promoting them to custom commands in cypress/support/commands.ts so specs read like plain English:

Cypress.Commands.add('otpboxSignUp', (email: string) => {
  cy.get('[name="email"]').type(email);
  cy.get('button[type="submit"]').click();
});

Cypress.Commands.add('otpboxEnterCode', (inboxId: string) => {
  cy.task('otpboxWaitForOtp', inboxId, { timeout: 25000 }).then((code: any) => {
    cy.get('[name="otp"]').type(code);
    cy.get('button[type="submit"]').click();
  });
});

This is a thin convenience layer over the same two tasks — nothing about the OTPBox side changes, it's purely to keep spec files readable once several tests need the same signup-and-verify sequence.

CI notes

Next steps

Ready to try it against your own app? Create a free account

← Back to OTPBox