DEV Community

Dishant Singh
Dishant Singh

Posted on

How I Stopped Manually Clicking Verification Emails in My Selenium Tests

There is a specific kind of frustration that comes from having a nearly-complete automated test suite — one that handles login, forms, payments, navigation — and then hitting a wall at email verification.

You can automate the browser flawlessly. But the moment your app sends a "verify your email" message, someone has to actually read it. In most teams, that someone is still a human, which means your CI pipeline either skips the step, mocks it away, or breaks at 2 AM when a developer accidentally uses the shared test inbox.

I spent way too long doing it wrong before finding a setup that actually works. Here is everything I learned.


Why the Common Approaches All Break Eventually

I have tried most of the workarounds that come up when you search for this problem:

Shared Gmail account — works until it doesn't. Two parallel test jobs submit signups at the same time and each reads the other's OTP. Flaky tests that are impossible to debug.

Hardcoded temp mail URL — you need a second Selenium session pointed at a web UI to read it. Fragile DOM scraping, timing issues, and the site goes down at the worst time.

Mocking the email step — this one is actually the most dangerous. Your signup flow "passes" in CI but your real email pipeline could be silently broken. You find out in production.

nodemailer + IMAP — complex OAuth setup, token rotation, and you still have to write regex to parse the OTP from the email body.

What I actually needed was simple: a fresh inbox per test, fast delivery, and OTP extraction that does not require me to maintain regex patterns. That is what the FreeCustom.Email API provides.


The Setup

Register at freecustom.email, then install the CLI:

curl -fsSL freecustom.email/install.sh | sh
fce login
# Opens browser → signs you in → saves key to OS keychain
Enter fullscreen mode Exit fullscreen mode

Or pick your package manager:

brew tap DishIs/homebrew-tap && brew install fce   # Homebrew
npm install -g fcemail@latest                      # npm
choco install fce                                  # Chocolatey (Windows)
scoop bucket add fce https://github.com/DishIs/scoop-bucket && scoop install fce  # Scoop
go install github.com/DishIs/fce-cli@latest        # Go
Enter fullscreen mode Exit fullscreen mode

All releases: github.com/DishIs/fce-cli/releases

For the Python tests below, install the SDK:

pip install freecustom-email selenium pytest pytest-asyncio
Enter fullscreen mode Exit fullscreen mode

The Pattern That Works

Every email verification automation follows three steps:

1. Create a fresh disposable inbox
2. Submit it in the signup form
3. Extract the OTP
Enter fullscreen mode Exit fullscreen mode

The key word is fresh. Each test gets its own inbox. Parallel tests never collide.


Python + Selenium — Full Example

Here is a pytest fixture-based setup I have been using in production CI:

# conftest.py
import asyncio
import os
import time
import pytest
from freecustom_email import FreeCustomEmail

fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])

@pytest.fixture
def inbox():
    addr = f"sel-{int(time.time())}@ditapi.info"
    asyncio.run(fce.inboxes.register(addr))
    yield addr
    asyncio.run(fce.inboxes.unregister(addr))
Enter fullscreen mode Exit fullscreen mode
# tests/test_signup.py
import asyncio
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from freecustom_email import FreeCustomEmail
import os

fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
APP_URL = os.environ.get("APP_URL", "https://staging.myapp.com")

@pytest.fixture
def driver():
    opts = webdriver.ChromeOptions()
    opts.add_argument("--headless")
    opts.add_argument("--no-sandbox")
    drv = webdriver.Chrome(options=opts)
    yield drv
    drv.quit()

@pytest.mark.asyncio
async def test_signup_and_verify(driver, inbox):
    wait = WebDriverWait(driver, 15)

    # Fill the signup form with our temp inbox address
    driver.get(f"{APP_URL}/signup")
    wait.until(EC.presence_of_element_located((By.ID, "email"))).send_keys(inbox)
    driver.find_element(By.ID, "password").send_keys("TestPass123!")
    driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

    # Wait for the OTP screen
    wait.until(EC.presence_of_element_located((By.ID, "otp-input")))

    # This is the part that used to require regex — now it's one line
    otp = await fce.otp.wait_for(inbox, timeout=30)
    assert otp and otp.isdigit()

    # Fill OTP and verify
    driver.find_element(By.ID, "otp-input").send_keys(otp)
    driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()

    wait.until(EC.url_contains("/dashboard"))
    assert "/dashboard" in driver.current_url
Enter fullscreen mode Exit fullscreen mode

The fce.otp.wait_for() call handles all the polling and parsing internally. It returns the numeric code (or verification link, depending on your app). No regex to write or maintain.


JavaScript + Selenium — Same Idea

import { Builder, By, until } from 'selenium-webdriver';
import { FreecustomEmailClient } from 'freecustom-email';

const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });

async function testSignup() {
  const inbox = `sel-${Date.now()}@ditapi.info`;
  await fce.inboxes.register(inbox);

  const driver = await new Builder().forBrowser('chrome').build();
  try {
    await driver.get(`${process.env.APP_URL}/signup`);
    await driver.wait(until.elementLocated(By.id('email')), 10_000);
    await driver.findElement(By.id('email')).sendKeys(inbox);
    await driver.findElement(By.id('password')).sendKeys('TestPass123!');
    await driver.findElement(By.css("button[type='submit']")).click();
    await driver.wait(until.elementLocated(By.id('otp-input')), 10_000);

    const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
    await driver.findElement(By.id('otp-input')).sendKeys(otp);
    await driver.findElement(By.css("button[type='submit']")).click();
    await driver.wait(until.urlContains('/dashboard'), 10_000);

    console.log(`✓ Signup verified. OTP was: ${otp}`);
  } finally {
    await driver.quit();
    await fce.inboxes.unregister(inbox);
  }
}

testSignup();
Enter fullscreen mode Exit fullscreen mode

What the OTP Output Actually Looks Like

When you call fce otp <inbox> from the CLI (useful for debugging), here is what you get:

────────────────────────────────────────────────
  OTP
────────────────────────────────────────────────

  OTP   ·  212342
  From  ·  noreply@myapp.com
  Subj  ·  Your verification code
  Time  ·  20:19:54
Enter fullscreen mode Exit fullscreen mode

In a shell script you can grab just the code:

OTP=$(fce otp "$INBOX" | grep "OTP   ·" | awk '{print $NF}')
Enter fullscreen mode Exit fullscreen mode

Parallel Tests — This Is Where It Gets Good

Because every test has its own inbox, you get parallel safety for free:

pip install pytest-xdist
pytest -n 4 tests/test_signup.py
Enter fullscreen mode Exit fullscreen mode

Four Chrome sessions, four isolated inboxes, no collisions. This was impossible with any shared inbox approach.


GitHub Actions

- name: E2E signup tests
  env:
    FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
    APP_URL: ${{ vars.STAGING_URL }}
  run: |
    pip install selenium pytest pytest-asyncio pytest-xdist freecustom-email
    pytest -n 4 tests/ -v
Enter fullscreen mode Exit fullscreen mode

Get FCE_API_KEY from the dashboard after running fce login locally. Set it as a repository secret.


What About Magic Links Instead of Numeric OTPs?

Some apps send a clickable link rather than a code. The FCE API handles this too — the message object includes a verification_link field:

messages = await fce.messages.list(inbox)
msg = await fce.messages.get(inbox, messages[0].id)
driver.get(msg.verification_link)
Enter fullscreen mode Exit fullscreen mode

Plans and Pricing

The OTP extraction endpoint requires Growth plan ($49/mo). WebSocket real-time delivery (instead of polling) requires Startup ($19/mo). Everything else — inbox creation, message fetching, the CLI — works on the free tier.

Plan Price OTP extraction WebSocket
Free $0
Developer $7/mo
Startup $19/mo
Growth $49/mo

Full details at freecustom.email/api/pricing.


Links


If you are testing email verification flows today, what approach are you using? Curious whether anyone has found a better pattern for magic links specifically — drop it in the comments.

Top comments (0)