<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SMS & OTP Testing Notes]]></title><description><![CDATA[Practical notes on testing SMS and OTP verification: Python and Playwright examples, receiving real OTP codes in automated tests, webhooks, CI pipelines and the]]></description><link>https://sms-otp-testing.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 04:13:42 GMT</lastBuildDate><atom:link href="https://sms-otp-testing.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Cypress SMS OTP Testing with a Real Phone Number and cy.task]]></title><description><![CDATA[Cypress SMS OTP testing: rent a real phone number via API, read the code in cy.task, release the number on failure and run the test nightly in CI.]]></description><link>https://sms-otp-testing.hashnode.dev/cypress-sms-otp-testing-real-phone-number</link><guid isPermaLink="true">https://sms-otp-testing.hashnode.dev/cypress-sms-otp-testing-real-phone-number</guid><category><![CDATA[Cypress]]></category><category><![CDATA[Testing]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[test-automation]]></category><category><![CDATA[QA]]></category><dc:creator><![CDATA[Willette]]></dc:creator><pubDate>Wed, 28 Oct 2026 10:14:10 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>Disclosure:</strong> I work with HeroSMS, the virtual number provider used in the examples.</p>
</blockquote>
<p>Your Cypress SMS OTP test hits a wall as soon as your sign-up, login or SMS 2FA form sends a real code: the browser under test has no phone, and <code>cy.intercept</code> can only pretend the SMS went out. That's fine for most runs, but it never proves that a real SMS reaches a real number in your launch country, or that your app accepts the code.</p>
<p>This tutorial builds that check for your own app: rent a number from an API, read the code on the Node side with <code>cy.task</code>, type it, release the number whether the test passes or fails, and run it nightly in GitHub Actions.</p>
<h2>Can Cypress read SMS?</h2>
<p>No. Cypress drives a browser and can't reach a phone or SIM. To receive SMS in Cypress, call an SMS API from Node with <a href="https://docs.cypress.io/api/commands/task"><code>cy.task()</code></a>: the task polls until the message arrives, extracts the code and returns it to the test, which types it into your form.</p>
<h2>Cypress SMS OTP: mock, test credentials or a real number</h2>
<table>
<thead>
<tr>
<th>Option</th>
<th>What it proves</th>
<th>Cost</th>
<th>When to run</th>
</tr>
</thead>
<tbody><tr>
<td>Mock (<code>cy.intercept</code> or a stubbed SMS client)</td>
<td>UI and app logic</td>
<td>Free</td>
<td>Every PR</td>
</tr>
<tr>
<td>Provider test credentials or fixed test numbers</td>
<td>Your API call to the provider; UI with a fixed code</td>
<td>Free</td>
<td>Every PR</td>
</tr>
<tr>
<td>Real rented number</td>
<td>A real SMS arrives and the whole flow accepts it</td>
<td>Paid per number</td>
<td>Nightly, pre-release</td>
</tr>
</tbody></table>
<p><a href="https://www.twilio.com/docs/iam/test-credentials">Twilio's test credentials</a> don't connect to real phone numbers, and <a href="https://firebase.google.com/docs/auth/web/phone-auth">Firebase's fictional test numbers</a> (up to 10, each with a fixed code) send no SMS. Hosted services such as <a href="https://mailosaur.com/docs/automation/cypress/sms-testing">Mailosaur</a> and MailSlurp give you real numbers with their own Cypress integrations (Mailosaur ships <code>cy.mailosaurGetMessage</code>, and its guide requires an account with SMS enabled). If your second factor is an authenticator app (TOTP), skip the phone and generate codes from the shared secret, for example with <a href="https://www.npmjs.com/package/otplib"><code>otplib</code></a> inside a <code>cy.task</code>.</p>
<p>I covered the same mocks-on-PR, real-SMS-on-schedule split <a href="https://dev.to/willette_samer/how-to-test-an-sms-otp-flow-end-to-end-in-python-with-a-real-phone-number-api-2d9a">in Python</a> and <a href="https://dev.to/willette_samer/how-to-automate-otp-in-playwright-real-sms-codes-via-webhook-in-github-actions-42p2">for Playwright with a webhook</a>; this Cypress version polls from the config process, which already runs in Node, so you don't need a public webhook URL.</p>
<h2>What you need</h2>
<ul>
<li>Cypress 16.2 or later on Node 22, 24 or 26.</li>
<li>A <a href="https://hero-sms.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=hashnode_cypress-otp">HeroSMS</a> API key and some balance. You buy an <em>activation</em>: one number reserved for one service in one country for about 20 minutes (the exact end is in <code>expiredAt</code>). Numbers cover 180+ countries, so you can usually test in the country you launch in.</li>
<li>These environment variables, read only by Node:</li>
</ul>
<pre><code class="language-bash">export HEROSMS_API_KEY="&lt;your API key&gt;"
export OTP_SERVICE="&lt;service code&gt;"   # 2-4 letters
export OTP_COUNTRY="&lt;country ID&gt;"     # an integer
export APP_URL="https://staging.example.com"
# optional: export HEROSMS_MAX_PRICE="&lt;price cap per number&gt;"
</code></pre>
<p>Service codes and country IDs come from the getServicesList and getCountries endpoints in the HeroSMS API reference (key required); <code>GET /activations/offers/sms</code> without filters also returns them: service codes are the keys of <code>data</code>, country IDs the keys inside each service.</p>
<h3>Keeping the API key out of the browser</h3>
<p>The variables above deliberately have no <code>CYPRESS_</code> prefix. Cypress copies <code>CYPRESS_*</code> variables into its <code>env</code> config, which a spec can read with <code>cy.env()</code> (<a href="https://docs.cypress.io/app/guides/environment-variables">environment variables guide</a>), and spec and support files run in the browser. Unprefixed variables stay in the Node process that runs <code>cypress.config.js</code>. Never pass the key as a <code>cy.task</code> argument either: the Command Log prints arguments.</p>
<h2>Step 1: a small SMS API client in Node</h2>
<pre><code class="language-javascript">// cypress/tasks/herosms.js - runs in Node only, never in the browser
const API = 'https://hero-sms.com/api/v1';

class HeroSmsError extends Error {
  constructor(status, body, retryAfter) {
    super(`HTTP ${status} ${body.title ?? ''}: ${body.details ?? ''}`);
    this.name = 'HeroSmsError';
    this.status = status;
    this.title = body.title ?? '';
    this.info = body.info;
    this.retryAfter = retryAfter;
  }
}

async function call(method, path, body) {
  const key = process.env.HEROSMS_API_KEY;
  if (!key) throw new Error('HEROSMS_API_KEY is not set');
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `ApiKey ${key}`,
      Accept: 'application/json',
      ...(body ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(15_000), // a stuck request can't hang the task
  });
  const text = await res.text();
  if (res.status &gt;= 400) {
    let err = {};
    try { err = JSON.parse(text) ?? {}; } catch { /* not JSON */ }
    const retryAfter = Number.parseInt(res.headers.get('retry-after') ?? '', 10) || 0;
    throw new HeroSmsError(res.status, err, retryAfter);
  }
  return text ? JSON.parse(text) : null; // 204 No Content -&gt; null
}

async function buyNumber(service, country, maxPrice) {
  const res = await call('POST', '/activations', {
    service, country, amount: 1, verificationType: 'sms',
    ...(maxPrice ? { maxPrice } : {}),
  });
  if (!res?.data?.[0]) throw new Error('POST /activations returned no activation');
  return res.data[0];
}

module.exports = {
  HeroSmsError,
  buyNumber,
  lastOtp: (id) =&gt; call('GET', `/activations/${id}/otp/last`),
  finish: (id) =&gt; call('POST', `/activations/${id}/finish`), // closes it, no refund
  cancel: (id) =&gt; call('DELETE', `/activations/${id}`), // cancels and refunds
};
</code></pre>
<p>Errors come back as JSON with <code>title</code> and <code>details</code>. Each request aborts after 15 seconds, since the Cypress docs don't say whether a timed-out task's handler is stopped. <code>phone</code> comes back as an integer without the <code>+</code>.</p>
<h2>Step 2: receive the SMS in Cypress with cy.task</h2>
<pre><code class="language-javascript">// cypress.config.js
const { defineConfig } = require('cypress');
const sms = require('./cypress/tasks/herosms');

const open = new Map(); // activationId -&gt; { boughtAt, gotOtp }, lives for the whole run
const CLOSED = new Set(['ACTIVATION_NOT_ACTIVE', 'CANCELED', 'FINISHED', 'REFUNDED']);
const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, Math.max(0, ms)));
const dropped = (e) =&gt; e.name === 'TimeoutError'
  || (e instanceof TypeError &amp;&amp; e.message === 'fetch failed');

async function buy() {
  const service = process.env.OTP_SERVICE;
  const country = Number.parseInt(process.env.OTP_COUNTRY ?? '', 10);
  const cap = process.env.HEROSMS_MAX_PRICE;
  const maxPrice = cap ? Number(cap) : undefined;
  if (!service || !Number.isInteger(country) || Number.isNaN(maxPrice)) {
    throw new Error('Set OTP_SERVICE and OTP_COUNTRY; HEROSMS_MAX_PRICE must be a number');
  }
  const a = await sms.buyNumber(service, country, maxPrice);
  open.set(a.id, { boughtAt: Date.now(), gotOtp: false });
  return { id: a.id, phone: `+${a.phone}` };
}

async function waitForCode({ id, timeoutMs = 90_000 }) {
  const deadline = Date.now() + timeoutMs;
  let delay = 2_000;
  while (Date.now() &lt; deadline) {
    try {
      const otp = (await sms.lastOtp(id))?.data ?? {};
      const code = otp.smsCode || otp.smsText?.match(/\b\d{4,8}\b/)?.[0];
      if (code) {
        const entry = open.get(id);
        if (entry) entry.gotOtp = true;
        return code; // the only value that crosses into the browser
      }
      if (otp.smsText) throw new Error('SMS has no 4-8 digit code; check your template');
    } catch (e) {
      if (e.status === 429) {
        await sleep(Math.min((e.retryAfter || 5) * 1000, deadline - Date.now()));
        continue;
      }
      // 400/404 on an open activation = no SMS yet; a slow or dropped request is retried
      const notYet = e instanceof sms.HeroSmsError
        &amp;&amp; [400, 404].includes(e.status) &amp;&amp; !CLOSED.has(e.title);
      if (!notYet &amp;&amp; !dropped(e)) throw e;
    }
    await sleep(Math.min(delay, deadline - Date.now()));
    delay = Math.min(delay * 1.5, 10_000);
  }
  throw new Error(`No SMS for activation ${id} within ${timeoutMs / 1000}s`);
}

// Never throws: a cleanup error must not hide the real test failure
async function release(id, wait = false) {
  const entry = open.get(id);
  if (!entry) return null;
  try {
    if (entry.gotOtp) {
      await sms.finish(id); // the code was used: close it, no refund
    } else {
      try {
        await sms.cancel(id); // no SMS: cancel, money back to balance
      } catch (e) {
        if (e.title === 'OTP_RECEIVED') {
          await sms.finish(id); // the SMS came late
        } else if (e.title === 'EARLY_CANCEL_DENIED' &amp;&amp; wait) {
          const minSec = Number(e.info?.minActivationTime) || 120;
          await sleep(entry.boughtAt + minSec * 1000 + 5_000 - Date.now());
          return release(id); // one more try, no more waiting
        } else {
          throw e;
        }
      }
    }
    open.delete(id);
  } catch (e) {
    // Too early, rate-limited, server or network error: keep it for the next sweep
    const later = !(e instanceof sms.HeroSmsError) || e.status &gt;= 500
      || e.status === 429 || e.title === 'EARLY_CANCEL_DENIED';
    if (!later) open.delete(id);
    console.error(`[otp] release ${id}: ${e.message}`);
  }
  return null;
}

async function releaseAll(wait = false) {
  for (const id of [...open.keys()]) await release(id, wait);
  return null;
}

module.exports = defineConfig({
  retries: { runMode: 1, openMode: 0 }, // every attempt buys a new number
  e2e: {
    baseUrl: process.env.APP_URL || 'http://localhost:3000',
    // jobs without the key (PR runs, forks) never pick up the paid spec
    excludeSpecPattern: process.env.HEROSMS_API_KEY ? [] : ['**/*.real-sms.cy.js'],
    setupNodeEvents(on, config) {
      on('task', {
        'otp:buy': buy,
        'otp:wait': waitForCode,
        'otp:release': (id) =&gt; release(id),
        'otp:releaseAll': () =&gt; releaseAll(),
      });
      on('after:run', () =&gt; releaseAll(true)); // last chance; may wait out the early-cancel window
      return config;
    },
  },
});
</code></pre>
<p>Every handler returns a value or <code>null</code>, since a task that resolves with <code>undefined</code> fails. The registry lives in the config process: it spans every spec in a run, and each parallel CI machine has its own. If your package.json has <code>"type": "module"</code>, name both files <code>.cjs</code> and require <code>./cypress/tasks/herosms.cjs</code> with the extension.</p>
<h3>Waiting for the SMS without cy.wait()</h3>
<p>A fixed <code>cy.wait(10000)</code> is too short for a slow carrier and wastes time on a fast one. <code>cy.task</code> runs its handler once and never retries it, so the polling lives in Node, backs off from 2 to 10 seconds and returns as soon as the code arrives.</p>
<p><code>GET /activations/{id}/otp/last</code> returns <code>smsCode</code> (the code) and <code>smsText</code> (the full message), and either can be null. If <code>smsCode</code> is empty, the regex takes the first 4-8 digit number, so adjust it to your SMS template. The response before the first SMS isn't documented, so 400, 404 and an empty <code>data</code> count as "not yet" unless <code>title</code> says the activation is closed (a 404 from a wrong id will look like a timeout). The loop also honors <code>Retry-After</code> on 429, which only the offers endpoint documents.</p>
<h2>Step 3: automate the OTP sign-up or login test in Cypress</h2>
<h3>cy.getSmsCode() and typing into six OTP boxes</h3>
<pre><code class="language-javascript">// cypress/support/commands.js - bundled into the browser: no keys here
Cypress.Commands.add('getSmsCode', (id, timeoutMs = 90_000) =&gt;
  // Node gives up at timeoutMs; +20 s covers its last request
  cy.task('otp:wait', { id, timeoutMs }, { timeout: timeoutMs + 20_000, log: false }));

// [data-cy=otp] is either the input itself or a wrapper around one box per digit
Cypress.Commands.add('typeOtp', (code) =&gt; {
  cy.get('[data-cy=otp]').then(($otp) =&gt; {
    if ($otp.is('input')) {
      cy.wrap($otp).clear().type(code, { log: false });
      return;
    }
    cy.get('[data-cy=otp] input').should('have.length', code.length);
    [...code].forEach((digit, i) =&gt; {
      cy.get('[data-cy=otp] input').eq(i).clear().type(digit, { log: false });
    });
  });
});
</code></pre>
<p>For split inputs, <code>typeOtp</code> types one digit per box by index, so it doesn't rely on the component moving focus, and re-querying each box survives re-renders.</p>
<h3>The spec, with a wrong-code check</h3>
<pre><code class="language-javascript">// cypress/e2e/otp-signup.real-sms.cy.js
describe('Cypress SMS OTP sign-up', () =&gt; {
  let activation;

  beforeEach(() =&gt; {
    activation = undefined;
    cy.task('otp:releaseAll', null, { log: false, timeout: 120_000 }); // leftovers
    cy.task('otp:buy', null, { timeout: 30_000 }).then((a) =&gt; { activation = a; });
  });

  afterEach(() =&gt; {
    if (activation) cy.task('otp:release', activation.id, { log: false });
  });

  it('rejects a wrong code, then signs up with the real one', () =&gt; {
    cy.visit('/signup');
    cy.get('[data-cy=phone]').type(activation.phone);
    cy.get('[data-cy=send-code]').click();

    cy.getSmsCode(activation.id).then((code) =&gt; {
      const wrong = (code.startsWith('1') ? '2' : '1') + code.slice(1);
      cy.typeOtp(wrong);
      cy.get('[data-cy=verify]').click();
      cy.get('[data-cy=otp-error]').should('be.visible');

      cy.typeOtp(code);
      cy.get('[data-cy=verify]').click();
    });
    cy.location('pathname').should('eq', '/welcome');
  });
});
</code></pre>
<ul>
<li>If your component submits on the last digit, drop the verify clicks and assert the result directly.</li>
<li>For a login or SMS 2FA test, visit your login page instead, type the code at the second-factor prompt and assert the session, for example <code>cy.getCookie('session').should('exist')</code>. Expired-code and rate-limit tests follow the same shape: wait out your app's code lifetime or hit its limit, then assert the error.</li>
<li>If verification runs on another origin, wrap it in <a href="https://docs.cypress.io/api/commands/origin"><code>cy.origin()</code></a> and pass the code via <code>args</code>.</li>
<li><code>log: false</code> hides commands, not the input: failure screenshots still show the digits, so treat CI artifacts as sensitive.</li>
<li>If your app rejects the number, release it and buy another; <code>phoneException</code> in the purchase body excludes prefixes up front. The API also offers a free replacement via <code>POST /activations/{id}/replace</code>; if you use it, register the activation it returns in <code>open</code> the way <code>buy()</code> does, so cleanup still releases it.</li>
<li>If your form splits the country picker from the number, strip the country calling code from <code>activation.phone</code> before typing (the purchase response has it as <code>countryPhoneCode</code>; return it from <code>buy()</code> too).</li>
</ul>
<h2>Step 4: cleanup that survives failed tests</h2>
<p>The Cypress <a href="https://docs.cypress.io/app/core-concepts/best-practices">best practices</a> warn that <code>after</code> hooks may not run if a run is interrupted, and recommend cleaning up before tests. So cleanup has four layers:</p>
<ol>
<li><code>afterEach</code> releases the current number: the normal path.</li>
<li><code>beforeEach</code> sweeps leftovers before buying. With <a href="https://docs.cypress.io/app/guides/test-retries">retries</a>, every attempt re-runs it and buys a new number; the failed attempt's number is closed if its early-cancel window has passed, or stays in the registry.</li>
<li><a href="https://docs.cypress.io/api/node-events/after-run-api"><code>after:run</code></a> releases the rest and is the only layer that waits: the reference's error examples include an <code>EARLY_CANCEL_DENIED</code> refusal during a number's first 2 minutes, so a test that fails within seconds gets its refund here. In <code>cypress open</code> it fires only with <code>experimentalInteractiveRunEvents: true</code>.</li>
<li>If the runner itself dies, no Node code runs. Unused numbers are refunded to your balance, and <code>GET /activations</code> lists anything still open.</li>
</ol>
<p><code>release()</code> branches on the error <code>title</code>, not the HTTP status, because the reference doesn't document how the v1 endpoint reports these refusals. A late SMS (<code>OTP_RECEIVED</code>) gets <code>finish</code>; a 5xx, 429 or network error keeps the number for the next sweep. Other refusals are only logged; that number stays open until it expires.</p>
<h2>Step 5: run it nightly in GitHub Actions</h2>
<pre><code class="language-yaml"># .github/workflows/otp-nightly.yml
name: otp-nightly
on:
  schedule:
    - cron: '0 4 * * 1-5' # 04:00 UTC on weekdays; every run buys a number
  workflow_dispatch:

concurrency:
  group: otp-nightly
  cancel-in-progress: false # a canceled run never reaches after:run

jobs:
  real-sms:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - uses: cypress-io/github-action@v7
        with:
          spec: cypress/e2e/otp-signup.real-sms.cy.js
        env:
          HEROSMS_API_KEY: ${{ secrets.HEROSMS_API_KEY }}
          OTP_SERVICE: ${{ vars.OTP_SERVICE }}
          OTP_COUNTRY: ${{ vars.OTP_COUNTRY }}
          HEROSMS_MAX_PRICE: ${{ vars.HEROSMS_MAX_PRICE }}
          APP_URL: ${{ vars.APP_URL }}
</code></pre>
<p>With the <a href="https://github.com/cypress-io/github-action">Cypress GitHub Action</a>, the step's <code>env:</code> reaches <code>process.env</code> in the config process, not the browser. Your PR workflow doesn't pass the key, so <code>excludeSpecPattern</code> keeps the paid spec out of PR runs. <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows">Scheduled workflows</a> run on the default branch in UTC, can start late, and are disabled after 60 days without activity in a public repository.</p>
<h2>Use it on your own app only</h2>
<p>Test only apps you own or are authorized to test. A rented number is yours only for the activation window, so don't tie it to an account you'll need later.</p>
<h2>Wrap-up</h2>
<p>Keep mocks on every PR and add one scheduled run against a real number: it catches what mocks can't, such as delivery failures, broken SMS templates and country-specific quirks. If you'd rather have codes pushed than polled, the HeroSMS API reference documents an <code>sms-incoming</code> webhook; the Playwright post above shows that setup.</p>
<p><em>This post was drafted with AI assistance and checked against the HeroSMS API reference and the Cypress docs.</em></p>
]]></content:encoded></item></channel></rss>