Bestätigungscode in Selenium & Playwright testen
Um die E-Mail-Verifizierung deiner Registrierung in Selenium oder Playwright zu testen, braucht der Test ein echtes Postfach, dessen Bestätigungscode er lesen kann. Mit der OTPGmail-API mietet der Test eine @gmail.com-Adresse, trägt sie ins Formular ein, fragt den Code per API ab und storniert die Bestellung, wenn keiner kommt – dann gibt es das Geld zurück.
Dieser Ratgeber richtet sich an QA-Teams und Entwickler, die den Registrierungsablauf ihrer eigenen Anwendung oder einer Testumgebung automatisieren, für die sie zuständig sind. Du bekommst ein Playwright-Fixture in TypeScript, eine kleine Python-Klasse für Selenium und die Stellen, an denen solche Tests in der Praxis scheitern.
Wann sich ein echtes Postfach lohnt
Für Unit-Tests und die lokale Entwicklung ist ein Mail-Catcher auf dem eigenen Server schneller und kostet pro Lauf nichts. Ein echtes Postfach ist sinnvoll, wenn:
- deine Staging-Umgebung über die echte Versandinfrastruktur an echte Gmail- oder iCloud-Adressen zustellt,
- du vor einem Release prüfen willst, ob deine Bestätigungsmails bei Gmail überhaupt ankommen,
- dein Formular Domains von Wegwerf-Diensten ablehnt und der Test sich wie ein normaler Nutzer verhalten soll.
Im deutschsprachigen Raum verschicken viele Shops und Newsletter nach der Anmeldung einen Bestätigungslink statt eines Codes (Double-Opt-in). Der Test muss deshalb beides verarbeiten können; die Beispiele unten tun das. Weil deine eigene Anwendung nicht in der Dienstliste steht, verwendest du den Dienstcode ot („Beliebiger Dienst“). Er kostet mehr als ein gelisteter Dienst, und die Gmail-Variante empfängt einen Code pro Postfach. Braucht ein Test Registrierung und späteren Login, plane zwei Bestellungen. Die Unterschiede zwischen Gmail und iCloud stehen auf der Seite Beliebiger Dienst.
Ablauf und Felder der API
POST /v1/ordersmit dem Body{"service": "ot"}und einem neuenIdempotency-Key(UUID). Die Antwort ist ein Array; du brauchstdata[0].orderIdunddata[0].email.- Die Adresse ins Formular eintragen und die Bestätigungsmail auslösen.
- Alle 3–5 Sekunden
GET /v1/orders/{orderId}abfragen. Solangestatusgleichwaiting_codeist, warten; beicompletedsteht der neueste Wert inotp[otp.length - 1].code. cancelledheißt: BiscodeDeadlineAt(höchstens 25 Minuten) kam nichts, die Bestellung ist bereits erstattet.- Endet der Test vorher,
POST /v1/orders/{orderId}/cancelaufrufen; vor dem ersten Code gibt es 100 % zurück.
otp[].code ist meist ein kurzer Code oder ein Link. Kann das System keinen Code herauslesen, steht die ganze E-Mail (HTML oder Text) in diesem Feld; ist der Wert länger als 80 Zeichen, enthält er einen Zeilenumbruch oder HTML, suchst du Code oder Link selbst darin.
Playwright: ein Fixture, das mietet und aufräumt
Ein eigenes Fixture hält die Tests kurz: Es mietet vor dem Test, liefert Adresse und Wartefunktion und storniert danach, falls kein Code kam. Der API-Schlüssel bleibt im Node-Prozess, nicht im Browser.
// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { randomUUID } from 'node:crypto';
const HOST = 'otpgmail.net';
const API = `https://${HOST}/v1`;
const auth = { Authorization: `Bearer ${process.env.OTPGMAIL_API_KEY}` };
type Bestellung = { status: string; otp: { code: string }[] };
type Postfach = { email: string; warteAufCode: () => Promise<string> };
export const test = base.extend<{ postfach: Postfach }>({
postfach: async ({ request }, use) => {
const res = await request.post(`${API}/orders`, {
headers: { ...auth, 'Idempotency-Key': randomUUID() },
data: { service: 'ot' },
timeout: 15_000,
});
const body = await res.json();
if (!body.success) throw new Error(body.error.code); // z. B. NO_MAILS_AVAILABLE
const { orderId, email } = body.data[0];
let letzte: Bestellung = { status: 'waiting_code', otp: [] };
await use({
email,
warteAufCode: async () => {
await expect.poll(async () => {
const r = await request.get(`${API}/orders/${orderId}`, { headers: auth });
letzte = (await r.json()).data;
return letzte.status;
}, { message: 'Bestätigungscode', intervals: [4_000], timeout: 5 * 60_000 })
.not.toBe('waiting_code');
expect(letzte.status, 'kein Code: Bestellung storniert und erstattet').toBe('completed');
return letzte.otp[letzte.otp.length - 1].code;
},
});
if (letzte.status === 'waiting_code') {
await request.post(`${API}/orders/${orderId}/cancel`, { headers: auth }); // Erstattung
}
},
});
export { expect };
// registrierung.spec.ts
import { test, expect } from './fixtures';
test('Registrierung mit E-Mail-Bestätigung', async ({ page, postfach }) => {
test.setTimeout(6 * 60_000);
await page.goto('/registrieren');
await page.getByLabel('E-Mail-Adresse').fill(postfach.email);
await page.getByRole('button', { name: 'Konto anlegen' }).click();
const wert = await postfach.warteAufCode();
if (wert.startsWith('http')) {
await page.goto(wert); // Double-Opt-in: Bestätigungslink
} else {
await page.getByLabel('Bestätigungscode').fill(wert);
await page.getByRole('button', { name: 'Bestätigen' }).click();
}
await expect(page.getByText('E-Mail-Adresse bestätigt')).toBeVisible();
});
Die Beschriftungen („Konto anlegen“, „E-Mail-Adresse bestätigt“) gehören zu deiner Anwendung; ersetze sie durch deine Selektoren. Das Standard-Timeout eines Playwright-Tests ist kürzer als eine langsame E-Mail, deshalb setzt der Test es hoch.
Selenium-E-Mail-Verifizierung mit Python: eine Klasse mit Aufräumen
WebDriverWait.until wartet nicht nur auf Elemente, sondern auf jede Funktion, die den Treiber als Argument nimmt. Mit poll_frequency=4 fragt der Test die API alle vier Sekunden ab; der Kontextmanager storniert beim Verlassen, wenn kein Code kam.
import os, uuid, requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
HOST = "otpgmail.net"
API = f"https://{HOST}/v1"
AUTH = {"Authorization": f"Bearer {os.environ['OTPGMAIL_API_KEY']}"}
class Postfach:
def __enter__(self):
r = requests.post(f"{API}/orders", json={"service": "ot"},
headers={**AUTH, "Idempotency-Key": str(uuid.uuid4())}, timeout=15)
body = r.json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
self.order = body["data"][0]
self.code = None
return self
def _code_oder_false(self, _driver):
o = requests.get(f"{API}/orders/{self.order['orderId']}", headers=AUTH, timeout=15).json()["data"]
if o["status"] == "cancelled":
raise RuntimeError("kein Code: Bestellung storniert und erstattet")
return o["otp"][-1]["code"] if o["otp"] else False
def warte_auf_code(self, driver, sekunden=300):
self.code = WebDriverWait(driver, sekunden, poll_frequency=4).until(self._code_oder_false)
return self.code
def __exit__(self, *exc):
if self.code is None: # nur vor dem ersten Code möglich: 100 % zurück
requests.post(f"{API}/orders/{self.order['orderId']}/cancel", headers=AUTH, timeout=15)
driver = webdriver.Firefox()
try:
with Postfach() as p:
driver.get("https://staging.example.de/registrieren")
driver.find_element(By.NAME, "email").send_keys(p.order["email"])
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
wert = p.warte_auf_code(driver)
if wert.startswith("http"):
driver.get(wert) # Double-Opt-in: Bestätigungslink
else:
driver.find_element(By.NAME, "code").send_keys(wert)
finally:
driver.quit()
Nimm in Python requests statt urllib: Den Standard-User-Agent von urllib kann das CDN vor der API ablehnen.
CI, parallele Tests und Limits
- Schlüssel als Secret. Leg den API-Schlüssel als geheime Variable deiner CI ab, nie im Repository. Erzeugst du in der API-Doku einen neuen Schlüssel, funktioniert der alte sofort nicht mehr.
- Eine Bestellung pro Test. Parallele Worker teilen sich nie eine Adresse; schreib die Bestell-ID ins Testprotokoll.
- Rate-Limits. Die Standardwerte pro API-Schlüssel und die für dein Konto geltenden Werte zeigt die API-Doku. Wird ein Limit überschritten, kommt HTTP 429 mit
RATE_LIMITED; warte die Sekunden aus dem HeaderRetry-Afterab. Häufiger als alle 3–5 Sekunden abzufragen bringt keine schnelleren Codes. - Keine Miet-und-Storno-Schleifen. Storniert ein Konto innerhalb von 60 Minuten viele Bestellungen ohne Code, antwortet auch
POST /v1/ordersvorübergehend mitRATE_LIMITED. - Wartende Bestellungen. Wie viele Bestellungen gleichzeitig auf einen Code warten dürfen, ist pro Konto begrenzt (
WAITING_LIMIT_REACHED). - Wiederholen nach Timeout. Die Mietanfrage kann zu Spitzenzeiten einige Sekunden dauern; setz das Client-Timeout auf etwa 15 Sekunden und wiederhole nach einem Timeout mit demselben
Idempotency-Key.
Wann erstattet wird und wann nicht, steht unter Erstattung und Ersatz; den Gesamtablauf zeigt So funktioniert OTPGmail.
Grenzen
Ein gemietetes Postfach empfängt nur: Es sendet nicht, du meldest dich nicht darin an, und es ist nicht dauerhaft. Nutze diesen Weg für die Registrierung deiner eigenen Anwendung oder einer Umgebung, die du testen darfst, und nicht, um per Skript Konten bei fremden Plattformen anzulegen.
Häufig gestellte Fragen
Was kostet ein Testlauf, bei dem kein Code kommt?
Nichts. Kommt bis codeDeadlineAt kein Code, wird die Bestellung automatisch storniert und zu 100 % erstattet; endet dein Test früher, holst du dir das Geld mit dem Storno-Aufruf sofort zurück.
Gmail oder iCloud für Tests?
Beides sind echte Adressen. Preis und Code-Regeln unterscheiden sich je nach Postfachtyp; wer beide in getrennten Tests verwendet, sieht, ob die eigenen Mails bei beiden Anbietern ankommen. Mehr dazu in Gmail oder iCloud für Bestätigungscodes.
Ersetzt das einen lokalen Mail-Catcher?
Nein, es ergänzt ihn. Der Mail-Catcher bleibt für schnelle Läufe bei jedem Commit, das echte Postfach für geplante Läufe oder vor einem Release, wenn echte Zustellung geprüft werden soll.
Kann ich die Bestätigungsmail selbst lesen statt nur den Code?
Wenn kein Code erkannt wird, liefert die API die ganze E-Mail im Feld code. Einen Login ins Postfach oder IMAP-Zugriff gibt es nicht.
