How to test OTP verification end to end
A concrete recipe for OTP tests that do not flake: a real receivable address per test run, a long poll instead of a sleep, assertions on the mail itself, and CI wiring.
On this page
The short version of how to test otp verification is four moves: give each test run its own
real, receivable email address, drive the signup exactly as a user would, block until the code
actually arrives, then submit it and prove the account came out verified. No shared QA mailbox,
no IMAP client, and above all no sleep(5).
The concrete version follows, with the calls and the CI wiring.
What an OTP test has to prove
Every question about how to test otp verification comes down to four things, and most suites only check one:
- The mail leaves your app and arrives within a sane window.
- The code inside it is extractable and matches what the app expects.
- Submitting it flips the account to verified, and that sticks.
- A wrong, expired or reused code is rejected.
Worth being precise about what an emailed code is. It proves control of an address, which is exactly what signup verification needs. It is not a second authentication factor: NIST SP 800-63B states that methods which do not prove possession of a specific device, such as VOIP or email, shall not be used for out of band authentication. If you are testing real MFA you are testing a TOTP authenticator (RFC 6238), and no mail is involved at all, because the test generates that code itself.
1. One real inbox per test run
Reusing a single address across tests is how a suite starts reading its own crossed wires the moment it runs in parallel. Create a fresh inbox at the top of each test instead:
curl -X POST https://inboxdome.com/v1/inboxes \
-H "Authorization: Bearer $INBOXDOME_KEY" \
-H "Content-Type: application/json" \
-d '{"ttlSeconds":3600}'
# {"inbox":{"id":"inb_...","emailAddress":"h4x8s2kq@mail.inboxdome.com"}}
That address receives real SMTP mail, so your app’s actual sending path gets exercised: the
queue worker, the template renderer, the sending provider. Inboxes expire on their own, so
cleanup is optional, though DELETE /v1/inboxes/{id} purges one immediately if you want a tidy
run. The Email Testing API for OTP and verification flows covers the full
surface. If you are still picking a provider, the rundown of email testing tools worth
using compares the ones that offer a wait for message call.
2. Wait for the code, never sleep for it
This is where flaky OTP tests come from. A fixed sleep is either too short, so it fails on a slow CI box, or too long, so the suite takes twenty minutes. Long poll instead, with a timeout:
curl "https://inboxdome.com/v1/inboxes/$INBOX_ID/wait?for=otp&timeoutSeconds=30" \
-H "Authorization: Bearer $INBOXDOME_KEY"
# {"message":{...},"otp":{"value":"482913","confidence":0.95}}
The call returns the moment the message lands, not when the timer runs out, so a fast path stays fast and a slow one still passes. Two practical notes: raise the timeout to 60 or 90 seconds in CI, where queue workers are slower, and check your runner’s timeout documentation, because the per test timeout has to be larger than the email wait or the runner kills the test while the mail is still in flight.
3. Submit the code and prove verification stuck
In Playwright the whole thing reads as one linear test:
const inbox = await createInbox();
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.emailAddress);
await page.getByLabel('Password').fill('S3cure!pass');
await page.getByRole('button', { name: 'Create account' }).click();
const code = await waitForOtp(inbox.id); // blocks, max 30s
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
Do not stop at the success toast. A toast is a UI state, not a verified account. Log out, log back in, and hit something only a verified user can reach. Plenty of apps show “Verified” while the database column is still false. The Playwright OTP test setup has the helper functions this snippet calls.
4. Assert on the email, not just the code
The code can be correct while the mail itself is quietly broken: unauthenticated, wrong subject, dead button link. Those are production problems that a green signup test happily hides. One call waits and checks at the same time:
curl -X POST https://inboxdome.com/v1/inboxes/$INBOX_ID/assert \
-H "Authorization: Bearer $INBOXDOME_KEY" \
-H "Content-Type: application/json" \
-d '{"timeoutSeconds":45,"assertions":[
{"type":"otpPresent"},
{"type":"containsText","text":"verification code"},
{"type":"auth","check":"dkim"},
{"type":"auth","check":"spf"}]}'
# {"passed":true,"arrived":true,"arrivalSeconds":2.1,"results":[...]}
Read results rather than only passed, so a failure reports which condition broke. The SPF and
DKIM checks are the ones people skip and then regret: a DNS change that quietly breaks
authentication does not stop mail arriving in a test inbox, it stops it landing in real inboxes
weeks later.
5. Test the unhappy paths
The happy path rarely regresses. These do:
- Wrong code. Rejected with a clear error, and the account stays unverified.
- Expired code. Make the lifetime configurable and set it to a couple of seconds in the test environment rather than sleeping through the real window.
- Reused code. A code that verified once must not verify twice. Submit the same value again and assert the rejection.
- Reissue. Hit “resend”, wait for the second mail, then check whether the first code still works. Whichever behaviour you intend, pin it with a test.
- Attempt limits. After a handful of wrong codes the app should throttle. Assert the lockout, and remember that a six digit code with no limit is guessable.
Link based flows have a separate trap worth reading about on its own in testing a password reset email end to end.
6. Run it in CI, or hand it to an agent
Store the key as a secret and let the pipeline do the three steps: create, wait, assert. The
GitHub Action wraps exactly those as create, wait and assert modes, with the address and
the code exposed as step outputs, and it is a thin wrapper over the same REST calls if you would
rather use curl directly. Details in
running email tests in GitHub Actions.
Budget the volume before wiring it up. One inbox per test on a free key gives you 100 inbox tests a month, which comfortably covers a signup suite on every pull request but is not a load test. Full endpoint detail lives in the API reference for wait and assert.
If you develop through Claude Code, Cursor or another MCP client, the same operations are exposed
as MCP tools for AI coding agents, create_inbox, wait_for_email and
run_assertions among them, so an agent can register a test user, read the code out of the mail
and finish the flow itself. Same API underneath, reachable by the agent.
Put together, that is how to test otp verification without the flakes: a fresh inbox per run, a long poll instead of a sleep, assertions on the message as well as the code, and the unhappy paths covered on every pull request.
Disclosure: InboxDome publishes this blog and makes the products it mentions. We describe what they do rather than promise results.
Key takeaways
- Give every test run its own real, receivable address so parallel tests never read each other's codes.
- Long poll for the code with a timeout instead of sleeping; a fixed sleep is the single biggest source of flaky OTP tests.
- Finish the flow: prove the account is actually verified afterwards, not just that a success toast appeared.
- Assert on the email itself as well as the code, including SPF and DKIM results.
- Test the unhappy paths too: wrong code, expired code, reused code, and code reissue.
Sources
- NIST SP 800-63B, Digital Identity Guidelines (Authentication) — Section 5.1.3 on out of band authenticators, including why email does not qualify.
- Playwright documentation, Timeouts — How the per test timeout interacts with a long running step such as waiting for mail.
- RFC 6238, TOTP Time-Based One-Time Password Algorithm — The app based codes a test generates itself rather than receives by mail.
Links last checked August 30, 2026.
Frequently asked questions
Can I test OTP verification without a real mailbox?
You can stub the mail sender and read the code from your own database, and that is a fine unit test. It does not prove the message left your app, rendered, authenticated or arrived, which is what breaks in production. Use a real receivable inbox for the end to end test and stubs for everything below it.
How long should the test wait for the code?
Around 30 seconds locally and 60 to 90 seconds in CI, where your app and its queue workers are slower. Make sure the test runner's own per test timeout is larger than the email wait, or the runner kills the test while the mail is still in flight.
Is an emailed code a second authentication factor?
Not by the strict definition. NIST SP 800-63B says methods that do not prove possession of a specific device, such as VOIP or email, shall not be used for out of band authentication. An emailed code proves control of an address, which is what signup verification needs. For a real second factor, test a TOTP authenticator instead.
How do I test the code expiring without waiting for it?
Do not sleep through the real window. Either make the code lifetime configurable and set it to a couple of seconds in the test environment, or expire the code directly through a test only endpoint or a database write, then assert that the app rejects it.