Clones

Testing Twilio SMS and OTP flows without real numbers

Phone verification is on the critical path of most signups — and it’s the flow teams skip testing because real SMS feels untestable. It isn’t. A stateful clone gives you the same API surface with none of the pain.

Pratik Rana 8 min read

01Why real SMS breaks CI

Pointing your test suite at a live messaging provider fails in four predictable ways:

  • Cost. Every verification sends a billable message. A suite that runs 200 times a day across PRs turns your test budget into an invoice line item.
  • Latency. Real delivery takes seconds to minutes. A 30-second OTP wait inside a 90-second Playwright timeout is a coin flip.
  • Carrier filtering. Carriers aggressively filter A2P traffic to short-code ranges and test-looking numbers. Messages silently vanish; your test can’t distinguish “code not sent” from “code lost.”
  • Nondeterminism. Codes arrive out of order, retries collide, shared test numbers accumulate state from other runs. The result is exactly the kind of intermittent red described in our guide to flaky tests.

Some teams buy dedicated test phone numbers or negotiate sandbox access. Both still route through carrier networks, so latency and loss remain — you’ve paid for a faster coin flip, not determinism. Number pools shared across CI jobs introduce a subtler failure: two pipelines verifying against the same number concurrently, each consuming the other’s codes. The fix is removing the network from the loop entirely, which is what service clones are for.

02Clone semantics: same endpoints, deterministic codes

A Twilio clone implements the provider’s API contract — message create, lookup, verification services, the works — but keeps every message in a local, inspectable store. Your application doesn’t change: it still POSTs to a base URL, still reads back status callbacks. Only the base URL points at the clone.

# App code is unchanged except TWILIO_BASE_URL
curl -X POST "$CLONE/2010-04-01/Accounts/ACxxx/Messages.json" \
  -d To=+15550100042 -d From=+15550001111 \
  -d Body="Your code is 491722"

# The clone stores it deterministically — fetch it like the real API
curl "$CLONE/2010-04-01/Accounts/ACxxx/Messages.json?To=%2B15550100042"
# → {"messages":[{"body":"Your code is 491722","status":"delivered", ...}]}

Determinism is the point: for a given seed, the clone issues the same codes, in the same order, with the same timestamps. Your assertions stop guessing and start knowing. And because nothing leaves the machine, there’s no cost per run and no PII touching a third party — the same safety argument we make for third-party APIs in general.

03The virtual clock: testing expiry properly

OTP expiry is where hand-rolled tests give up. Nobody wants to wait five minutes for a code to lapse, so expiry logic ships untested until it breaks. A clone with a virtual clock solves this cleanly: advance simulated time, then act.

// Advance the clone's clock past expiry, then try the stale code
await clock.advance({ seconds: 301 });
await page.fill('#otp-input', codeFromClone);   // issued at t=0
await page.click('text=Verify');
await expect(page.getByText('Code expired')).toBeVisible();

// Request again → new code, old code rejected even if typed correctly
const fresh = await clone.getLatestCode('+15550100042');
expect(fresh).not.toBe(codeFromClone);

This covers the whole family of time bugs: resend throttles, retry lockouts after N attempts, “code valid for X minutes” copy drifting from server truth, and replay attempts. These are precisely the behaviors attackers probe, which makes them worth automating rather than spot-checking by hand.

04Webhook parity

Most integrations don’t poll — they consume webhooks: delivery receipts, inbound replies (STOP/HELP), verification status events. If your clone only fakes the REST API, you’ve tested half the surface. Look for three things in any substitute:

  • Lifecycle parity. queued → sent → delivered transitions fire in order, with realistic timing you control.
  • Signature correctness. Callbacks carry valid request signatures so your verification middleware is exercised, not bypassed.
  • Inbound simulation. You can inject an inbound “STOP” reply and assert your suppression logic fires.
// Simulate an inbound STOP and verify opt-out handling
await clone.injectInboundSms({ from: '+15550100042', body: 'STOP' });
await expect.poll(() => clone.suppressionList())
  .toContain('+15550100042');

Webhook parity also changes what you can test about ordering. Real providers retry undelivered callbacks and can deliver events out of order under load; a clone lets you replay those conditions on demand instead of waiting for a bad day in production. Assert that a duplicate verification.completed event is absorbed idempotently, that a status callback for a message your app never sent is rejected, and that signature failures return 403 rather than 500 — small checks that each map to a real outage class.

05Wiring it into the suite

In practice the pattern is small: point your app’s provider credentials at the clone before boot, run your flows, pull codes out of the clone when the UI asks for them.

// Playwright fixture
export async function otpFor(phone) {
  const msg = await clone.latestMessageTo(phone);
  return msg.body.match(/\b(\d{6})\b/)[1];
}
test('signup verifies by phone', async ({ page }) => {
  await signup(page, '+15550100042');
  await page.fill('#otp', await otpFor('+15550100042'));
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Molar ships Twilio, email, Stripe, auth, and S3 clones as one system, so a single scenario can mix an SMS code, a confirmation email, and a test-mode charge without real accounts anywhere. For the email side of the same pattern, see email workflow testing; for the payments leg, Stripe testing without real cards.

Keep one smoke check against the live provider behind a manual trigger. Clones verify your logic; a weekly manual pass confirms the vendor hasn’t changed their contract underneath you.

Run OTP flows in CI without a single real SMS

Molar’s Twilio clone speaks the real API, issues deterministic codes, bends time for expiry tests, and fires signed webhooks. No numbers rented, no bills accrued.