A one-time code is designed to be unpredictable and short-lived. That is exactly what makes it hard to automate: your test can drive the login form up to the point where the app asks for a six-digit code, and then it stalls, because the code only exists in an inbox, a phone, or an authenticator app your test can't reach.
You have three realistic ways to close that gap: capture the code from a real inbox or number, compute a TOTP code yourself from the shared seed, or wire an environment-scoped bypass. Each trades away something — this covers all three, and when to reach for each.
Why the naive approaches fail
Two tempting shortcuts don't survive contact with a real suite.
Disabling 2FA for the test account changes the thing you're testing. The login path with a second factor is a different code path — different redirects, different session state, different edge cases around expiry and retry. A green suite against a single-factor account tells you nothing about the flow real users hit.
Hard-coding a static code only works against a mock that always returns 123456. The moment the code is genuinely random, or expires after 30 seconds, or is single-use, a literal in your test is a flake waiting to happen. Whatever you do, the code has to be derived at runtime from wherever the system actually puts it.
Strategy 1: capture the real code
The most faithful approach is to let the system send the real thing — an email OTP, a magic link, an SMS code — to a disposable inbox or number your test controls, then read it back out.
A common pattern is: register (or reuse) a throwaway address, trigger the flow, then poll for the newly arrived message and extract the code. When reusing an inbox, filter by timestamp, recipient and expected message so an old code cannot satisfy the check.
// otp-from-inbox.js
async function waitForCode(inbox, { timeout = 30_000 } = {}) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const msg = await inbox.latestMessage(); // fresh inbox for this attempt
const match = msg?.body?.match(/\b(\d{6})\b/); // 6-digit OTP
if (match) return match[1];
await new Promise((r) => setTimeout(r, 1_000));
}
throw new Error('No OTP arrived within timeout');
}
// login.spec.js — Playwright
import { test, expect } from '@playwright/test';
import { newInbox, waitForCode } from './support/mail';
test('login with emailed OTP', async ({ page }) => {
const inbox = await newInbox();
await page.goto('/login');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Send code' }).click();
const code = await waitForCode(inbox);
await page.getByLabel('Verification code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Trade-offs. This tests the whole delivery chain — template rendering, send, deliverability, the actual code the user sees. Nothing is faked. The cost is speed and moving parts: you depend on a mail or SMS provider, delivery latency forces a poll with a timeout, and SMS in particular costs money per message and rate-limits under load. Reserve real-number SMS for a handful of smoke checks rather than every run.
Current TestVibe Mailbox supports configured email inboxes. The underlying vendor plugin is hidden from the customer catalog, and current Mailbox SMS is unavailable. Review the Mailbox setup for your installation; generic real-number SMS automation requires its own supported provider.
Strategy 2: compute the TOTP code from the shared seed
When the second factor is an authenticator app (Google Authenticator, Authy, 1Password), the app doesn't send anything — both sides compute the same six digits from a shared secret and the current time. That's TOTP, RFC 6238, and the key insight is that your test can run the same algorithm.
At enrollment the app shows a secret (the otpauth:// QR payload). Capture that Base32 seed once, store it as a test secret, and generate the current code on demand:
// totp.spec.js
import { test, expect } from '@playwright/test';
import { generate } from 'otplib'; // current v13 async API
test('login with authenticator TOTP', async ({ page }) => {
const secret = process.env.TOTP_SEED; // Base32 enrollment seed
await page.goto('/login');
await page.getByLabel('Email').fill('qa+totp@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
await page.getByRole('button', { name: 'Sign in' }).click();
if (!secret) throw new Error('TOTP_SEED is required');
const code = await generate({ secret }); // match enrolled algorithm/digits/period
await page.getByLabel('Authenticator code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page).toHaveURL(/\/app/);
});
Trade-offs. This is fast, offline, and deterministic — no inbox, no network, no provider bill — so it's viable on every run. It also faithfully exercises the real TOTP verification path. Two caveats. First, the seed is a live credential: it must live in a secret store, never in the repo, and it should belong to a dedicated test account, never a real employee. Second, mind the clock. The default time step is 30 seconds; match the enrolled settings and verifier’s accepted window; if you generate it and the request lands after the window rolls, verification fails. Some verifiers accept adjacent windows, but if you see intermittent failures, generate the code immediately before filling it, and make sure the machine's clock is synced.
Strategy 3: an environment-scoped bypass
Sometimes 2FA isn't the subject of the test — it's a tollbooth in front of the checkout flow you actually care about. Here a deliberate, tightly-scoped bypass earns its keep: a server-side flag that, only in non-production environments, accepts a fixed code or skips the second factor for flagged test accounts.
# values.staging.yaml — never present in production config
auth:
otp:
testBypassEnabled: true
# accept this fixed code for accounts tagged `qa-only`
bypassCode: "000000"
bypassAccountTag: "qa-only"
The bar for doing this safely is high, and it's worth stating plainly:
- The flag must be impossible to enable in production. Gate it on the environment name at startup and fail the boot if it's set in prod, rather than trusting a config file to stay correct.
- Scope it to marked test accounts, so a leaked bypass code can't unlock a real user.
- Log every use loudly, so an unexpected bypass in an environment you forgot about is visible.
Trade-offs. It's the fastest and least flaky option, and it lets you cover the hundred tests behind the login without paying the 2FA tax each time. But you are no longer testing the real second factor — so keep at least one test per environment that goes through Strategy 1 or 2 for real. The bypass is for breadth; the genuine path is for the truth.
Choosing per suite, not per project
These aren't mutually exclusive. A healthy suite usually mixes them: a bypass for the broad functional coverage that happens to sit behind auth, TOTP computation for the authenticator flow itself, and a small number of real inbox or SMS captures as end-to-end smoke tests that prove the whole delivery chain works. Match the technique to what each test is actually asserting, and keep the real second factor in play somewhere.
Want to see this against your own login flow? Get early access, or read the plugins documentation for supported integration setup.