GUIDES / SELENIUM

Selenium email testing, without the sleeps.

Selenium gives you explicit waits for the DOM — WebDriverWait, expected conditions — and nothing at all for what happens outside the browser. An email is the definition of outside the browser. After this guide, your Selenium WebDriver tests can receive a real email — signup OTP, verification link, password reset — and assert on it: in Python with a first-class SDK, or in Java, C#, or Ruby with four plain REST calls.

Quick answer. Give every test its own real inbox from an inbox API, drive the browser to trigger the email at that address, then make one long-poll request that the server holds open until the message arrives — and read the OTP or verification link from the extracted fields, not from a regex over raw HTML. No Thread.sleep, no IMAP session against a shared Gmail account, no polling loop to tune.

One honesty note before anything else, because most Selenium content assumes Java and then hand-waves the email part: MailFixture ships SDKs for Python and JS/TS only. If your Selenium suite is Python, you get a typed client and the whole email flow is three lines. If it's Java, C#, or Ruby, you get a small, stable REST API and your language's standard HTTP client — shown plainly below, not disguised as an SDK we don't have. The REST surface is deliberately small enough that this is a fair deal, not a consolation prize.

Why email breaks Selenium suites

Suites improvise around the email gap, and the improvisations fail in three recognizable ways.

1. time.sleep(10) / Thread.sleep(10_000) after the submit click. Delivery latency is a distribution, not a constant. The sleep that passed all week is too short the day your email provider has a slow hour, and pure wasted wall-clock time in the other ninety-five percent of runs. Multiply by every email-touching test and the suite is both slow and flaky — the worst quadrant.

2. One shared mailbox for the whole suite. A team Gmail account read over IMAP, or one address on a catch-all domain. Every test now matches against every message every other test triggered. The suite passes in one run order and fails in another; run it on a Selenium Grid with four parallel nodes and tests start reading each other's OTPs. It looks like flakiness. It's shared mutable state — the exact thing you'd never accept anywhere else in the suite.

3. Regex over raw HTML to find "the link". href="([^"]*)" finds a link, all right: the logo link, the unsubscribe link, or the open-tracking pixel, depending on which template version shipped last sprint. The test breaks on every copy change, and the failure mode is silent — it clicks the wrong URL and fails three steps later with an assertion that names the wrong problem.

The fix for all three is structural, not clever: an inbox per test, created through an API so it exists the moment you need it; a server-held long-poll instead of any sleep or retry loop; and typed extraction — the OTP and the classified links are fields on the message, computed server-side, so your test asserts on otp and links, not on markup.

Where this fits — and where it doesn't

Be honest about layers before reaching for any tool:

If your suite never triggers real email delivery — everything is mocked at the provider boundary — you don't need any of this. The moment a Selenium test clicks "Sign up" against a staging environment that really sends, you do. The strategy guide maps the three layers in full.

Python: Selenium + the SDK

The SDK is stdlib-only (mailfixture on PyPI, v0.5.x, Python 3.9+, zero dependencies) and synchronous, so it drops into a Selenium suite with nothing to configure. Everything below assumes pytest, because that's where most selenium-python suites live; the same calls work under unittest or plain scripts.

The client reads MAILFIXTURE_API_KEY from the environment — keep the key in CI secrets, never in the repo. The conftest carries the two fixtures from the pytest guide — a session-scoped client (stateless, free to share) and a function-scoped inbox (the test's state — never widen its scope) — plus a driver fixture. If you already have the first two, reuse them unchanged:

conftest.pypip install mailfixture selenium
import pytest
from selenium import webdriver
from mailfixture import MailFixture

@pytest.fixture(scope="session")
def mail():
    return MailFixture()  # reads MAILFIXTURE_API_KEY

@pytest.fixture
def inbox(mail):
    inbox = mail.create_inbox(ttl_seconds=900)
    yield inbox
    inbox.delete()  # runs pass or fail; TTL covers killed runs

@pytest.fixture
def driver():
    driver = webdriver.Chrome()
    yield driver
    driver.quit()

The test drives the browser to trigger the email, makes one call that returns when the email lands, and types the code back into the page:

test_signup.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

APP = "https://staging.example.com"

def test_signup_sends_otp(driver, inbox):
    driver.get(f"{APP}/signup")
    driver.find_element(By.NAME, "email").send_keys(inbox.address)
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

    otp = inbox.wait_for_otp(timeout=30)  # seconds — held open server-side

    driver.find_element(By.NAME, "code").send_keys(otp)
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    WebDriverWait(driver, 10).until(EC.url_contains("/welcome"))

wait_for_otp long-polls: the server holds the request open and answers the moment a message with an extractable code arrives, and raises MailFixtureTimeout only when the deadline is exhausted. The timeout is a ceiling, not a duration.

Two details worth engraving:

Verification and magic links ride the same fixture. The server classifies every extracted link — verify, reset, unsubscribe, other — so you ask for the kind you mean instead of scraping hrefs:

test_verification.py
def test_signup_sends_verify_link(driver, inbox):
    driver.get(f"{APP}/signup")
    driver.find_element(By.NAME, "email").send_keys(inbox.address)
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

    link = inbox.wait_for_link(kind="verify", timeout=30)
    driver.get(link.url)  # the browser clicks the real link

    WebDriverWait(driver, 10).until(EC.url_contains("/verified"))

That's the whole Python story. Because each test owns its inbox, running the suite across a Selenium Grid or with pytest -n auto needs no coordination — no shared mailbox, no locks, no cross-node collisions.

Java, C#, Ruby: the REST pattern

No SDK exists for these languages, and this guide won't pretend otherwise. What exists instead is a REST API small enough to wrap in an afternoon: bearer auth, JSON in and out, RFC 7807 errors. The email loop is four requests. Here it is in curl, which translates line for line to java.net.http.HttpClient, .NET's HttpClient, or Ruby's Net::HTTP:

the four calls · any language with an HTTP client
# 1. create an inbox (before the test navigates anywhere)
curl -s -X POST https://api.mailfixture.com/v1/inboxes \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 900}'
# 201 → {"id": "9c2e…", "email_address": "k3v9pw2q1d@…", …}

# 2. drive Selenium to sign up with that email_address, then long-poll:
#    the server holds this request open until a message arrives (wait ≤ 60s)
curl -s "https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages?wait=45" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"
# → {"messages": [{"id": "a1f4…", "subject": "Your code", …}]}

# 3. read the extracted OTP (or /links for classified links)
curl -s "https://api.mailfixture.com/v1/messages/$MSG_ID/otp" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"
# → {"best": "482913", "candidates": […]}

# 4. teardown
curl -s -X DELETE "https://api.mailfixture.com/v1/inboxes/$INBOX_ID" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"

The long-poll in step 2 is the piece that kills the sleeps: instead of a client-side retry loop, one request waits server-side and returns the instant the message lands. In Java, that's an ordinary blocking call — just make sure the HTTP client's timeout exceeds the wait value, or the client will kill a perfectly healthy long-poll:

Poll.java · java.net.http
HttpClient http = HttpClient.newHttpClient();
String key = System.getenv("MAILFIXTURE_API_KEY");

HttpRequest poll = HttpRequest.newBuilder()
    .uri(URI.create("https://api.mailfixture.com/v1/inboxes/"
        + inboxId + "/messages?wait=45"))
    .header("Authorization", "Bearer " + key)
    .timeout(Duration.ofSeconds(60))   // must exceed wait=45
    .build();

HttpResponse<String> res =
    http.send(poll, HttpResponse.BodyHandlers.ofString());

Three rules keep a hand-rolled wrapper honest — they're exactly what the Python SDK does internally:

If your Selenium tests run under JUnit or NUnit, the create/delete calls belong in @BeforeEach/@AfterEach (or their equivalents) — the same inbox-per-test shape as the pytest fixture, in your runner's native idiom.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Passes alone, fails in the suite or on the Grid — shared mailbox across testsFailures change with run order; a test asserts another test's OTPInbox per test, created in setup, deleted in teardown
Intermittent timeout even though the email arrived — fixed sleep, or a client-side polling loop with gapsEmail timestamp falls inside the sleep window; 429s in the logOne long-poll request with wait=; chain with since
Test hangs until the CI job's ceiling kills ittimeout=30_000 in Python (seconds, not ms)Job killed externally; no MailFixtureTimeout raisedtimeout=30; add pytest-timeout set above the SDK deadline
Long-poll returns empty at 60s despite asking for more — server clamps wait to 60Response arrives at exactly 60s with {"messages": []}Chain requests under your own deadline, cursor via since
Healthy wait killed by the HTTP client (Java/C#/Ruby) — client timeout below the wait valueTimeout exception at N seconds while wait > NSet the client timeout above wait (the SDKs use wait + 15)
Wrong URL clicked; failure surfaces steps later — regex href-scraping picked the logo or tracking linkThe visited URL isn't a verification URLClassified links: kind="verify" in the SDK, class on the wire — never regex
OTP assertion fails with an old code — matched a previous message (resend flows)The asserted code belongs to an earlier emailFresh inbox per test; match= and since when several emails race

Edge cases

Grid parallelism. Inbox-per-test is what makes parallel Selenium safe. Each node's tests mint their own addresses through the API; there is nothing shared to lock and no coordination protocol. If you're currently serializing email tests to protect a shared mailbox, this deletes that constraint.

Teardown vs killed runs. Delete the inbox in teardown, and set a ttl_seconds anyway (60 to 2,592,000; the examples use 900). Teardown handles pass and fail; the TTL handles the run your CI provider killed mid-flight. Expired inboxes stop receiving immediately.

Negative expectations are bounded waits. "Unsubscribed users get no email" is not a sleep plus an empty-list assert. In Python it's with pytest.raises(MailFixtureTimeout): inbox.wait_for_message(timeout=10); over REST it's a wait=10 poll asserting the empty list. Be honest about what it proves — no email within ten seconds, not never — and keep the bound tight.

Secrets. The API key rides in the Authorization header from an environment variable or CI secret. Never bake it into the Selenium project, and never pass it through browser-visible state — it's a server credential, and the browser under test has no business seeing it.

Retention. Received messages expire on a plan-based schedule (days, not forever). Tests read a message seconds after it arrives, so this never bites a suite — but don't design a workflow that expects to fetch last month's test email.

What this does not prove

NOTE Test what you own. Point these tests at your own product's signup and verification flows — not at third-party services' registration forms. That's the ToS boundary, and it's the same everywhere.

Next steps

100 messages/mo free · works with the Selenium suite you already have
Start free Read the pytest quickstart