<?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[Temp SMS Dev Notes]]></title><description><![CDATA[Temp SMS Dev Notes]]></description><link>https://tempsmss.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:10:59 GMT</lastBuildDate><atom:link href="https://tempsmss.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[SMS OTP Testing Checklist for QA Testers]]></title><description><![CDATA[An SMS OTP testing checklist for QA: happy path, wrong and expired codes, resend, rate limits, number formats, security leaks and real delivery.]]></description><link>https://tempsmss.hashnode.dev/sms-otp-testing-checklist-qa</link><guid isPermaLink="true">https://tempsmss.hashnode.dev/sms-otp-testing-checklist-qa</guid><category><![CDATA[Testing]]></category><category><![CDATA[QA]]></category><dc:creator><![CDATA[Temp SMS]]></dc:creator><pubDate>Sat, 07 Nov 2026 03:15:19 GMT</pubDate><content:encoded><![CDATA[<p>This SMS OTP testing checklist covers what a QA tester should check on any one-time-code flow: the happy path, wrong and expired codes, resend behaviour, rate limits, phone number formats and locales, security leaks, and a final check on real networks. Run it in staging with a stubbed or sandboxed SMS provider first, then repeat the delivery items with real phones before release.</p>
<h2>Before you run the SMS OTP testing checklist</h2>
<p>Get these details from the spec or the developers, because they're what you test against. If nobody can tell you, that's your first bug report.</p>
<ul>
<li>Code length and whether it's digits only</li>
<li>Code lifetime in seconds</li>
<li>Wrong attempts allowed before the code is burned</li>
<li>Resend cooldown, and the maximum sends per number, per account and per IP address</li>
<li>Whether a new code cancels the previous one</li>
<li>Where codes go in test environments: a stub outbox, the provider's test mode or a log</li>
</ul>
<p>Make sure test environments can't text real strangers. Reserved fictional numbers, such as 555-0100 to 555-0199 in North American format, are a cheap safeguard for stubbed tests.</p>
<h2>Happy path</h2>
<ul>
<li><input disabled type="checkbox" /> The code arrives in the expected time.</li>
<li><input disabled type="checkbox" /> The message names the product, shows the code, states the expiry, says not to share it and is in the user's language.</li>
<li><input disabled type="checkbox" /> The correct code is accepted and the user lands in the right place.</li>
<li><input disabled type="checkbox" /> The same code is rejected the second time.</li>
<li><input disabled type="checkbox" /> Pasting the code works, including with spaces around it.</li>
<li><input disabled type="checkbox" /> The phone's keyboard offers the code from the incoming text, where the platform supports it.</li>
<li><input disabled type="checkbox" /> Codes starting with 0 work: 012345 must not become 12345.</li>
</ul>
<h2>Wrong codes</h2>
<ul>
<li><input disabled type="checkbox" /> A wrong code shows a clear error without hinting which digits were right.</li>
<li><input disabled type="checkbox" /> After the allowed number of wrong attempts, the code stops working even if the correct one is then entered.</li>
<li><input disabled type="checkbox" /> Letters, symbols and the wrong number of digits are handled without a crash.</li>
<li><input disabled type="checkbox" /> A code sent to one number or account can't verify a different one.</li>
</ul>
<h2>Expiry</h2>
<ul>
<li><input disabled type="checkbox" /> A code entered just before the stated lifetime is accepted.</li>
<li><input disabled type="checkbox" /> A code entered just after it is rejected, with a message that says what to do next.</li>
<li><input disabled type="checkbox" /> The lifetime in the SMS text matches what the server enforces.</li>
</ul>
<p>Rather than waiting real minutes, ask for a configurable lifetime in staging or clock control in automated tests.</p>
<h2>Resend</h2>
<ul>
<li><input disabled type="checkbox" /> The resend button is disabled during the cooldown, with a visible countdown.</li>
<li><input disabled type="checkbox" /> Reloading the page doesn't reset the cooldown.</li>
<li><input disabled type="checkbox" /> A double click sends one SMS, not two.</li>
<li><input disabled type="checkbox" /> After a resend, the old code behaves as specified, usually rejected.</li>
<li><input disabled type="checkbox" /> Editing the phone number restarts the flow and sends to the new number only.</li>
</ul>
<h2>Rate limits</h2>
<ul>
<li><input disabled type="checkbox" /> Sends per number are capped, and the error explains when to try again.</li>
<li><input disabled type="checkbox" /> Sends per account and per IP address are capped too.</li>
<li><input disabled type="checkbox" /> Requests to many different numbers from one source are limited, which blocks SMS pumping: bots triggering texts to run up your bill.</li>
<li><input disabled type="checkbox" /> Limits reset after the window ends.</li>
<li><input disabled type="checkbox" /> The response doesn't reveal whether a number is registered.</li>
</ul>
<h2>Phone number formats and locales</h2>
<ul>
<li><input disabled type="checkbox" /> The country selector defaults sensibly and is searchable.</li>
<li><input disabled type="checkbox" /> International format with + is accepted, as is the local format with a leading 0 for countries that use one, such as the UK or the Netherlands.</li>
<li><input disabled type="checkbox" /> A leading 0 typed after the country code is handled: corrected or clearly rejected.</li>
<li><input disabled type="checkbox" /> Spaces, dashes, brackets and pasted numbers are cleaned up; too short or too long ones get a helpful error.</li>
<li><input disabled type="checkbox" /> Landline or VoIP numbers are handled as the policy says, with a clear message.</li>
<li><input disabled type="checkbox" /> Translated messages stay within the expected number of SMS parts. One character outside the basic GSM-7 alphabet cuts a single message from 160 characters to 70.</li>
<li><input disabled type="checkbox" /> Right-to-left languages display correctly on the code screen.</li>
<li><input disabled type="checkbox" /> Error messages and the countdown are announced by screen readers, not only shown visually.</li>
</ul>
<h2>Security and privacy</h2>
<ul>
<li><input disabled type="checkbox" /> The code never appears in an API response, a URL, analytics, browser storage, server logs or error trackers.</li>
<li><input disabled type="checkbox" /> Verification happens on the server; changing the response in the browser doesn't skip the step.</li>
<li><input disabled type="checkbox" /> The code is tied to the sign-in session that requested it.</li>
<li><input disabled type="checkbox" /> Brute-force attempts spread across several sessions still hit a limit.</li>
</ul>
<h2>Real-network check</h2>
<p>Stubs prove the logic, not delivery. Before release, test on real phones:</p>
<ul>
<li><input disabled type="checkbox" /> Each country you launch in, ideally on more than one carrier.</li>
<li><input disabled type="checkbox" /> Delivery time is acceptable on a normal mobile connection.</li>
<li><input disabled type="checkbox" /> The sender name or number shows as expected in each country.</li>
<li><input disabled type="checkbox" /> Messages aren't filtered or mangled, and long ones reassemble.</li>
<li><input disabled type="checkbox" /> The full flow works on both major mobile platforms.</li>
</ul>
<p>For a quick manual check in several countries, the free public numbers on <a href="https://tempsmss.com/">Temp SMS</a> show incoming texts on a web page, with US, Canadian, UK, Dutch, Finnish, Swedish and Belgian numbers listed. Use them only with test accounts and test data: every message is visible to anyone, the numbers are shared and retired over time, and they must never be used for banking, a main email account or two-factor authentication.</p>
<h2>FAQ</h2>
<p><strong>How do you test OTP without a real phone?</strong>
Use a stubbed SMS provider or the provider's test mode, and read codes from the stub's outbox. Keep real phones for the final delivery check.</p>
<p><strong>What should be tested in SMS OTP verification?</strong>
Correct and wrong codes, expiry, resend, rate limits, number formats, localisation, security leaks and real delivery.</p>
<p><strong>How do I test OTP expiry without waiting?</strong>
Ask for a shorter lifetime in staging or for clock control in automated tests, then test just before and just after the limit.</p>
<p><strong>Should QA test OTP with real phone numbers?</strong>
Yes, for the final check. Stubs can't show carrier filtering, delivery delays or how the sender appears on a real phone.</p>
<hr />
<p><strong>More on SMS verification for developers:</strong></p>
<ul>
<li><a href="https://tempsmss.hashnode.dev/testing-sms-otp-python-pytest">Testing SMS OTP in Python with pytest</a></li>
<li><a href="https://tempsmss.hashnode.dev/testing-sms-otp-playwright">Testing SMS OTP with Playwright</a></li>
<li><a href="https://saadulla.github.io/sms-segment-counter/">SMS character counter (GSM-7 vs UCS-2)</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Testing SMS OTP in Python with pytest: Fake Provider, Frozen Clock, Rate Limits]]></title><description><![CDATA[Test SMS OTP in Python with pytest: a fake SMS provider, frozen clocks with time-machine and freezegun, and rate-limit tests without sleeping.]]></description><link>https://tempsmss.hashnode.dev/testing-sms-otp-python-pytest</link><guid isPermaLink="true">https://tempsmss.hashnode.dev/testing-sms-otp-python-pytest</guid><category><![CDATA[Python]]></category><category><![CDATA[pytest]]></category><category><![CDATA[Testing]]></category><dc:creator><![CDATA[Temp SMS]]></dc:creator><pubDate>Fri, 06 Nov 2026 19:32:20 GMT</pubDate><content:encoded><![CDATA[<p>Testing SMS OTP in Python with pytest comes down to controlling the two things a verification flow depends on: the SMS provider and the clock. Inject the provider so tests can swap in a fake that keeps an outbox, read the code from that outbox instead of guessing it, freeze time with time-machine or freezegun to test expiry to the second, and test rate limits by moving the clock rather than sleeping. Everything below was run with Python 3.14, pytest 9.2, time-machine 3.6 and freezegun 1.5, and all nine tests pass.</p>
<h2>The code under test</h2>
<p>A small service that sends a six-digit code, stores only an HMAC of it, accepts it once, burns it after five wrong guesses or five minutes, and allows three codes per number per hour:</p>
<pre><code class="language-python"># otp.py
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass
from typing import Protocol


class SmsSender(Protocol):
    def send(self, to: str, body: str) -&gt; None: ...


class RateLimited(Exception):
    pass


@dataclass
class _Pending:
    code_hash: str
    expires_at: float
    attempts: int = 0


class OtpService:
    CODE_TTL = 300      # seconds a code stays valid
    MAX_ATTEMPTS = 5    # wrong guesses before the code is burned
    MAX_SENDS = 3       # codes per number per window
    SEND_WINDOW = 3600  # seconds

    def __init__(self, sender: SmsSender, secret: bytes):
        self._sender = sender
        self._secret = secret
        self._pending: dict[str, _Pending] = {}
        self._sends: dict[str, list[float]] = {}

    def _hash(self, phone: str, code: str) -&gt; str:
        msg = f"{phone}:{code}".encode()
        return hmac.new(self._secret, msg, hashlib.sha256).hexdigest()

    def request_code(self, phone: str) -&gt; None:
        now = time.time()
        recent = [t for t in self._sends.get(phone, []) if now - t &lt; self.SEND_WINDOW]
        if len(recent) &gt;= self.MAX_SENDS:
            raise RateLimited(phone)

        code = f"{secrets.randbelow(10**6):06d}"
        self._sender.send(phone, f"Your Example code is {code}. It expires in 5 minutes.")
        # Only store the code once the provider accepted the message.
        self._pending[phone] = _Pending(self._hash(phone, code), now + self.CODE_TTL)
        self._sends[phone] = recent + [now]

    def verify(self, phone: str, code: str) -&gt; bool:
        pending = self._pending.get(phone)
        if pending is None or time.time() &gt;= pending.expires_at:
            self._pending.pop(phone, None)
            return False

        pending.attempts += 1
        if pending.attempts &gt; self.MAX_ATTEMPTS:
            del self._pending[phone]
            return False

        if hmac.compare_digest(pending.code_hash, self._hash(phone, code)):
            del self._pending[phone]  # single use
            return True
        return False
</code></pre>
<p>Two choices make it testable:</p>
<ul>
<li><strong>The sender is injected.</strong> Anything with a <code>send(to, body)</code> method will do, so tests never call a real provider or need a network.</li>
<li><strong>The clock is <code>time.time()</code>.</strong> time-machine doesn't move <code>time.monotonic()</code>, so expiry uses wall-clock timestamps, which you'd store in a database anyway.</li>
</ul>
<h2>A fake provider and an outbox fixture</h2>
<pre><code class="language-python"># tests/conftest.py
import re

import pytest

from otp import OtpService


class FakeSms:
    """Stands in for the SMS provider and keeps every message."""

    def __init__(self):
        self.outbox: list[tuple[str, str]] = []

    def send(self, to: str, body: str) -&gt; None:
        self.outbox.append((to, body))

    def last_code(self, to: str) -&gt; str:
        for number, body in reversed(self.outbox):
            if number == to:
                return re.search(r"\b(\d{6})\b", body).group(1)
        raise AssertionError(f"no SMS was sent to {to}")


@pytest.fixture
def sms():
    return FakeSms()


@pytest.fixture
def otp(sms):
    return OtpService(sender=sms, secret=b"test-only-secret")
</code></pre>
<p>Plus a <code>pytest.ini</code> so the tests can import <code>otp</code> from the project root:</p>
<pre><code class="language-ini">[pytest]
pythonpath = .
testpaths = tests
</code></pre>
<p>Reading the code from the outbox, rather than patching the random generator, tests the real path from generation to message text.</p>
<h2>Testing SMS OTP in Python with pytest: the core cases</h2>
<pre><code class="language-python"># tests/test_otp.py
import datetime as dt
import re
from unittest.mock import Mock

import pytest
from freezegun import freeze_time

from otp import OtpService, RateLimited

PHONE = "+12015550123"
START = dt.datetime(2026, 1, 1, 12, 0, tzinfo=dt.timezone.utc)


def test_sends_one_sms_with_a_six_digit_code(otp, sms):
    otp.request_code(PHONE)

    assert len(sms.outbox) == 1
    to, body = sms.outbox[0]
    assert to == PHONE
    assert body.startswith("Your Example code is ")
    assert len(sms.last_code(PHONE)) == 6


def test_correct_code_works_once(otp, sms):
    otp.request_code(PHONE)
    code = sms.last_code(PHONE)

    assert otp.verify(PHONE, code) is True
    assert otp.verify(PHONE, code) is False


def test_code_is_burned_after_too_many_wrong_guesses(otp, sms):
    otp.request_code(PHONE)
    code = sms.last_code(PHONE)
    wrong = f"{(int(code) + 1) % 10**6:06d}"

    for _ in range(OtpService.MAX_ATTEMPTS):
        assert otp.verify(PHONE, wrong) is False

    assert otp.verify(PHONE, code) is False
</code></pre>
<p>The wrong guess is derived from the real code, so it can never be right by accident. The numbers are in the fictional 555-0100 to 555-0199 range, so a misconfigured test can't text a real person.</p>
<h2>Code expiry with time-machine and freezegun</h2>
<p>time-machine ships a <code>time_machine</code> pytest fixture. <code>move_to(..., tick=False)</code> stops the clock and <code>shift()</code> moves it forward:</p>
<pre><code class="language-python">@pytest.mark.parametrize(("elapsed", "accepted"), [(299, True), (300, False)])
def test_code_expiry(otp, sms, time_machine, elapsed, accepted):
    time_machine.move_to(START, tick=False)
    otp.request_code(PHONE)

    time_machine.shift(elapsed)

    assert otp.verify(PHONE, sms.last_code(PHONE)) is accepted
</code></pre>
<p>Testing 299 and 300 seconds pins the boundary: change <code>&gt;=</code> to <code>&gt;</code> in <code>verify()</code> and the 300-second case fails.</p>
<p>The same check with freezegun, if your project already uses it:</p>
<pre><code class="language-python">def test_code_expiry_with_freezegun(otp, sms):
    with freeze_time("2026-01-01 12:00:00") as frozen:
        otp.request_code(PHONE)
        frozen.tick(301)
        assert otp.verify(PHONE, sms.last_code(PHONE)) is False
</code></pre>
<h2>Rate limits without sleeping</h2>
<pre><code class="language-python">def test_fourth_code_within_an_hour_is_refused(otp, sms, time_machine):
    time_machine.move_to(START, tick=False)
    for _ in range(3):
        otp.request_code(PHONE)
        time_machine.shift(60)

    with pytest.raises(RateLimited):
        otp.request_code(PHONE)
    assert len(sms.outbox) == 3


def test_rate_limit_resets_after_the_window(otp, sms, time_machine):
    time_machine.move_to(START, tick=False)
    for _ in range(3):
        otp.request_code(PHONE)

    time_machine.shift(3600)
    otp.request_code(PHONE)

    assert len(sms.outbox) == 4
</code></pre>
<p>Moving the clock instead of sleeping keeps both tests instant.</p>
<h2>Provider failures with Mock</h2>
<p>When the provider times out, the user must not end up with a code that works but never arrived:</p>
<pre><code class="language-python">def test_provider_failure_leaves_no_usable_code():
    sender = Mock()
    sender.send.side_effect = TimeoutError("provider timed out")
    service = OtpService(sender=sender, secret=b"test-only-secret")

    with pytest.raises(TimeoutError):
        service.request_code(PHONE)

    sender.send.assert_called_once()
    code = re.search(r"\d{6}", sender.send.call_args.args[1]).group()
    assert service.verify(PHONE, code) is False
</code></pre>
<h2>Running it</h2>
<pre><code class="language-bash">python3 -m venv .venv
source .venv/bin/activate
pip install pytest time-machine freezegun
pytest -q
</code></pre>
<pre><code class="language-text">.........                                                                [100%]
9 passed in 0.05s
</code></pre>
<h2>What unit tests can't tell you</h2>
<p>Fakes prove your logic, not delivery. Before launch, send real codes to real numbers in each country you serve. For a quick manual check, free public numbers on <a href="https://tempsmss.com/">Temp SMS</a> show incoming texts on a web page with no account. Anyone can read them and the numbers are retired over time, so use them only for throwaway test accounts, never banking, a main email or two-factor authentication.</p>
<h2>FAQ</h2>
<p><strong>Should I mock the SMS provider's SDK or my own wrapper?</strong>
Your own wrapper. A one-method <code>send(to, body)</code> interface is easy to fake, and the SDK call can be covered by a single integration test.</p>
<p><strong>Should I use time-machine or freezegun?</strong>
Either works for this. time-machine is a C extension with a built-in pytest fixture; freezegun is the older library and also moves <code>time.monotonic()</code>.</p>
<p><strong>How do I test OTP expiry without waiting?</strong>
Freeze the clock, request a code, move the clock past the expiry time and verify. No <code>time.sleep()</code> needed.</p>
<hr />
<p><strong>More on SMS verification for developers:</strong></p>
<ul>
<li><a href="https://tempsmss.hashnode.dev/testing-sms-otp-playwright">Testing SMS OTP with Playwright: stubs, fake timers and rate limits</a></li>
<li><a href="https://dev.to/tempsmss/how-to-build-a-secure-sms-otp-verification-endpoint-in-nodejs-3elo">How to build a secure SMS OTP verification endpoint in Node.js</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Testing SMS OTP with Playwright: Stubs, Fake Timers and Rate Limits]]></title><description><![CDATA[Test SMS OTP flows with Playwright: read codes from a stubbed outbox, fake the resend timer with page.clock and assert rate limits.]]></description><link>https://tempsmss.hashnode.dev/testing-sms-otp-playwright</link><guid isPermaLink="true">https://tempsmss.hashnode.dev/testing-sms-otp-playwright</guid><category><![CDATA[playwright]]></category><category><![CDATA[Testing]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Temp SMS]]></dc:creator><pubDate>Fri, 06 Nov 2026 17:21:40 GMT</pubDate><content:encoded><![CDATA[<p>Testing SMS OTP with Playwright works best when your app never talks to a real SMS provider during the run. Swap the provider for an outbox your tests can read, fetch the code with Playwright's <code>request</code> fixture, drive the resend countdown with <code>page.clock</code>, and assert rate limits at both the API and the UI level. Below is each piece in TypeScript, plus the part teams forget: keeping codes and credentials out of traces and CI logs.</p>
<h2>Where the stub belongs</h2>
<p>Playwright drives the browser, but your backend sends the SMS. <code>page.route()</code> only intercepts requests the <em>browser</em> makes, so it can't see the server-to-provider call. The stub goes on the server:</p>
<ul>
<li>Put the provider behind a small <code>SmsSender</code> interface.</li>
<li>In the test environment, bind it to an in-memory outbox that records <code>{ to, body }</code>.</li>
<li>Add a test-only endpoint, e.g. <code>GET /__test/sms?to=...</code> (and <code>DELETE</code> to reset that number's outbox and rate-limit counters). Mount it only when <code>NODE_ENV === 'test'</code> and require a token.</li>
</ul>
<p>Skip the fixed <code>000000</code> code: reading the real generated code exercises your generation and expiry logic.</p>
<h2>Helpers: a safe number and a polled code</h2>
<pre><code class="language-ts">// tests/helpers/otp.ts
import { expect, type APIRequestContext, type TestInfo } from '@playwright/test';

const headers = { 'x-test-token': process.env.TEST_TOKEN ?? '' };

// 555-0100..0199 is reserved for fiction, so a stub mistake never texts a real person.
export function testPhone(testInfo: TestInfo): string {
  return `+1202555${String(100 + testInfo.parallelIndex).padStart(4, '0')}`;
}

export async function resetPhone(request: APIRequestContext, to: string): Promise&lt;void&gt; {
  await request.delete('/__test/sms', { params: { to }, headers });
}

export async function latestCode(request: APIRequestContext, to: string): Promise&lt;string&gt; {
  let code = '';
  await expect.poll(async () =&gt; {
    const res = await request.get('/__test/sms', { params: { to }, headers });
    if (!res.ok()) return '';
    const { messages } = (await res.json()) as { messages: { body: string }[] };
    code = messages.at(-1)?.body.match(/\b(\d{6})\b/)?.[1] ?? '';
    return code;
  }, { timeout: 10_000 }).not.toBe('');
  return code;
}
</code></pre>
<p>One number per parallel worker plus a reset in <code>beforeEach</code> keeps tests from tripping each other's limits.</p>
<h2>The happy path</h2>
<pre><code class="language-ts">// tests/otp.spec.ts
import { test, expect } from '@playwright/test';
import { latestCode, resetPhone, testPhone } from './helpers/otp';

test.beforeEach(async ({ request }, testInfo) =&gt; {
  await resetPhone(request, testPhone(testInfo));
});

test('signs up with the code from the outbox', async ({ page, request }, testInfo) =&gt; {
  const phone = testPhone(testInfo);
  await page.goto('/signup');
  await page.fill('#phone', phone);
  await page.getByRole('button', { name: 'Send code' }).click();
  await expect(page.getByText('We sent a code')).toBeVisible();

  await page.fill('#otp', await latestCode(request, phone));
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByText('Your account is ready')).toBeVisible();
});
</code></pre>
<h2>Fake timers for the resend countdown</h2>
<p>Don't wait 30 real seconds for a "Resend" button. Install the fake clock <em>before</em> <code>goto</code>, then advance it:</p>
<pre><code class="language-ts">test('resend unlocks after the cooldown', async ({ page }, testInfo) =&gt; {
  await page.clock.install();
  await page.goto('/signup');
  await page.fill('#phone', testPhone(testInfo));
  await page.getByRole('button', { name: 'Send code' }).click();

  const resend = page.getByRole('button', { name: /resend/i });
  await expect(resend).toBeDisabled();
  await page.clock.runFor(30_000);
  await expect(resend).toBeEnabled();
});
</code></pre>
<p><code>runFor</code> fires every timer due in that window, which suits a countdown built on repeated <code>setTimeout</code> calls. The <a href="https://playwright.dev/docs/clock">Clock API docs</a> cover <code>fastForward</code>, <code>pauseAt</code> and the rest.</p>
<p>One limit: <code>page.clock</code> only changes time <em>in the browser</em>. Server-side code expiry follows the server's clock, so inject a clock into your backend or add a test-only way to age a code.</p>
<h2>Asserting rate limits</h2>
<p>Test the policy at the API level, where it's enforced:</p>
<pre><code class="language-ts">test('code requests are rate-limited per number', async ({ request }, testInfo) =&gt; {
  const phone = testPhone(testInfo);
  const statuses: number[] = [];
  for (let i = 0; i &lt; 4; i++) {
    const res = await request.post('/api/otp/send', { data: { phone } });
    statuses.push(res.status());
  }
  expect(statuses).toEqual([200, 200, 200, 429]); // our policy: 3 per window
});
</code></pre>
<p>Then test the UI's reaction. Here <code>page.route()</code> is the right tool, because the browser makes this request:</p>
<pre><code class="language-ts">test('explains the limit when the API returns 429', async ({ page }) =&gt; {
  await page.route('**/api/otp/send', (route) =&gt;
    route.fulfill({ status: 429, json: { error: 'too_many_requests' } }),
  );
  await page.goto('/signup');
  await page.fill('#phone', '+12025550123');
  await page.getByRole('button', { name: 'Send code' }).click();
  await expect(page.getByText(/too many attempts/i)).toBeVisible();
});
</code></pre>
<h2>Keep codes and secrets out of logs</h2>
<ul>
<li><strong>No real provider key in CI.</strong> With the outbox bound, there's nothing to leak.</li>
<li><strong>Treat traces as sensitive.</strong> <code>trace: 'retain-on-failure'</code> is a good default, but a trace holds DOM snapshots, network bodies and the action log, including values passed to <code>fill</code>. Keep artifact retention short and never publish them from a public repository.</li>
<li><strong>Don't log codes or tokens.</strong> Reporters capture stdout.</li>
<li><strong>Mask screenshots</strong> you take yourself: <code>await page.screenshot({ path: 'otp.png', mask: [page.locator('#otp')] })</code>.</li>
<li><strong>Prove the test endpoint is absent in production</strong> with a smoke test that expects <code>/__test/sms</code> to return 404.</li>
</ul>
<pre><code class="language-ts">// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000', trace: 'retain-on-failure' },
  webServer: { command: 'npm run start:test', url: 'http://localhost:3000' }, // app with the outbox sender
});
</code></pre>
<h2>What stubs can't tell you</h2>
<p>All of this proves your logic, not that a carrier delivers your message: sender rules, encoding and filtering differ by country. Before launch, do a short manual pass on staging with real numbers. Free public numbers on <a href="https://tempsmss.com/">Temp SMS</a> show incoming texts on a web page with no account, which makes that check quick. Anyone can read those messages, so use them only for disposable test accounts.</p>
<h2>FAQ</h2>
<p><strong>Can Playwright intercept the SMS my backend sends?</strong>
No. <code>page.route()</code> only sees browser requests. Stub the provider on the server and read the code through a test-only endpoint.</p>
<p><strong>Does <code>page.clock</code> make my OTP expire sooner?</strong>
Only in the browser. Server-side expiry needs a controllable server clock or a test hook.</p>
<p><strong>Should I use a fixed test code like 000000?</strong>
It skips your real generation and verification path, and it's risky if the flag ever reaches production.</p>
<hr />
<p><strong>More on SMS verification for developers:</strong></p>
<ul>
<li><a href="https://dev.to/tempsmss/how-to-build-a-secure-sms-otp-verification-endpoint-in-nodejs-3elo">How to build a secure SMS OTP verification endpoint in Node.js</a></li>
<li><a href="https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285">How to test SMS OTP verification without a real phone number</a></li>
</ul>
]]></content:encoded></item></channel></rss>