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.
The mechanics are always the same: register (or reuse) a throwaway address, trigger the flow, then poll for the newly arrived message and pull the code out with a regex.
// 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(); // your mail API
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.
TestVibe's MailSlurp plugin exists for exactly this seam. It reads one-time codes, magic links, and SMS codes delivered to disposable test inboxes and numbers, so a Gherkin step can fetch the code and hand it to the next step without you standing up your own mail poller. It's an Integrations plugin in the catalog; install it, set its API key under Variables & Secrets, and reference the code from your steps. See the plugins documentation for the install and secret-configuration flow.
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 { authenticator } from 'otplib';
test('login with authenticator TOTP', async ({ page }) => {
const secret = process.env.TOTP_SEED; // Base32 enrollment seed
const code = authenticator.generate(secret); // valid for this 30s window
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();
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. A code is valid for one 30-second window; if you generate it and the request lands after the window rolls, verification fails. Most servers accept the adjacent window, 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 the MailSlurp code-capture integration.