Bir Playwright testi genellikle nerede durur
Çoğu kayıt testi, "gelen kutunuzu kontrol edin" cümlesinde sona erer. Form dolduruldu, düğmeye tıklandı, sayfa doğru şeyi söyledi — ve bu cümleden sonra olan her şey varsayılır. Postanın gerçekten gittiği, içindeki kodun sunucunun beklediği kod olduğu, onay bağlantısının çalışan bir sayfa açtığı: bunların hepsi production'a bırakılır.
Orada bırakılır, çünkü sıradaki adım asenkrondur ve tarayıcının dışında yaşar; Playwright'ın tıklayacağı hiçbir şey yoktur. Bunu aşmanın üç alışılmış yolu, her biri farklı bir şeyi kanıtlar:
- E-posta gönderimini mock'lamak
- Kodunuzun
send()'i çağırdığını kanıtlar. Şablon, bağlantı ya da mesajı reddeden sağlayıcı hakkında hiçbir şey kanıtlamaz. - Yerel bir SMTP yakalayıcısı (Mailpit, MailHog, smtp4dev)
- İyi biçimlendirilmiş bir mesajın uygulamadan çıktığını kanıtlar. CI'da bir hizmet daha demektir ve yalnızca genel internette gerçekleşen hiçbir şey — gerçek bir MX araması, gerçek bir sağlayıcı, gerçek bir alıcı — burada gerçekleşmez.
- Gerçek bir tek kullanımlık posta kutusu
- Mesajın uygulamadan çıktığını, interneti geçtiğini, gerçek bir posta sunucusu tarafından kabul edildiğini ve işleyen bir kod taşıdığını kanıtlar. Tek bedeli, testin doğru şekilde beklemesi gerekmesidir — ki bu rehberin tamamı da zaten budur.
Bunun arkasındaki API, anahtar gerektirmeyen üç endpoint'tir ve referansta belgelenmiştir. Playwright'a özgü ayrıntılardan önce genel disiplini istiyorsanız, bir doğrulama akışını uçtan uca test etmek bunu her test çalıştırıcısı için ele alır; bu ise onu keyifli hale getiren fixture ile Playwright sürümüdür.
Her teste kendi gelen kutusunu veren bir fixture
Playwright'ın test.extend'i bunun için doğru yerdir: bir gelen kutusu, tıpkı page gibi, bir testin isimle istediği bir şey haline gelir ve adres her seferinde yeniden uydurulur. Sunucuda hiçbir şeyin oluşturulması gerekmez — bir posta kutusu, posta ona ulaştığı anda var olur — bu yüzden fixture, içinde rastgele bir adres ve üç küçük metot bulunan bir sınıftır.
import { test as base, expect } from '@playwright/test';
const API = 'https://grabmail.io/api/v1';
const DOMAIN = 'grabmail.io';
export type Summary = { id: string; from: string; subject: string; date: string };
export type Message = {
id: string; from: string; to: string; subject: string; date: string;
text: string | null; html: string | null;
};
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export class Inbox {
readonly address: string;
/** A mailbox nothing else in this run, or any previous run, is using. */
constructor(prefix = 'e2e') {
this.address = `${prefix}-${Math.random().toString(36).slice(2, 10)}@${DOMAIN}`;
}
/** Block until a matching message arrives, or the deadline passes. */
async waitFor(opts: { timeoutMs?: number; subjectContains?: string; fromContains?: string } = {}): Promise<Message> {
const deadline = Date.now() + (opts.timeoutMs ?? 60_000);
while (Date.now() < deadline) {
const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(this.address)}`);
if (res.status === 429) { // slow down, do not fail
await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000);
continue;
}
if (!res.ok) throw new Error(`GET /mailbox answered ${res.status} for ${this.address}`);
const { messages } = (await res.json()) as { messages: Summary[] };
const hit = messages.find(m =>
(!opts.subjectContains || m.subject.toLowerCase().includes(opts.subjectContains.toLowerCase())) &&
(!opts.fromContains || m.from.toLowerCase().includes(opts.fromContains.toLowerCase())));
if (hit) return this.read(hit.id);
await sleep(1000); // one read a second, never throttled
}
throw new Error(`no message for ${this.address} within the deadline`);
}
async read(id: string): Promise<Message> {
const res = await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`);
if (!res.ok) throw new Error(`GET /message answered ${res.status}`);
return res.json() as Promise<Message>;
}
/** Optional: everything expires on its own after a few days. Idempotent. */
async delete(id: string): Promise<void> {
await fetch(`${API}/message/${id}?mailbox=${encodeURIComponent(this.address)}`, { method: 'DELETE' });
}
}
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
await use(new Inbox());
},
});
export { expect };O dosyada iki şey bilinçli olarak yapılmıştır. Adres, dosya başına ya da çalıştırma başına değil, test başına rastgeledir, böylece paralel worker'lar birbirinin postasını hiçbir zaman okuyamaz. Ve waitFor, özet yerine mesajın tamamını döndürür — pratikte bir sonraki adımda her zaman gövdeyi istersiniz ve her testte bir çağrı eksik olması toplamda fark yaratır.
Mesajı bir sleep olmadan beklemek
Posta senkron değildir. Normalde iki ya da üç saniyede düşer, bazen yirmi saniye sürer ve testin bekleme şekli, paketin güvenilir olup olmadığını belirler. Kurallar kısadır:
- Bir deneme sayısı değil, bir son tarih.
for (let i = 0; i < 30; i++), döngünün o an çalıştığı hıza göre otuz denemedir — API hızlandıkça kısalır, göndereniniz yavaşladıkça uzar. Gerçek zamanlı bir son tarih ise her makinede aynı şeyi ifade eder. - Saniyede bir okuma. Belgelenmiş ritim budur ve hiçbir zaman kısıtlanmaz. Daha hızlısı
429ve birRetry-Afterbaşlığıyla reddedilir; daha hızlı yoklamak postayı daha erken getirmez. waitForTimeoutyok. Sabit bir sleep, yavaş bir günde ya çok kısa ya da diğer her günde çok uzun kalır. Döngü, mesaj var olduğu anda durur.- Filtreleyin; en yeni mesajı körü körüne almayın.
subjectContainsya dafromContainsgeçirin. Bir akış iki mesaj gönderdiğinde — bir hoş geldin ve bir kod — en yeni olanı her zaman istediğiniz olmayabilir.
Döngünün karşılaşacağı durum kodları ve her biriyle ne yapması gerektiği:
| Kod | Anlamı | Döngünün yaptığı |
|---|---|---|
200 | Posta kutusu okundu. count, 0 olabilir — boş bir posta kutusu asla 404 değildir. | Bir eşleşme arayın; yoksa bir saniye uyuyup yeniden deneyin. |
400 | Adres hatalı biçimlendirilmiştir. | Hata fırlatın. Bir yazım hatasını yeniden denemek onu düzeltmez. |
404 | Alan adı burada barındırılmıyor. | Hata fırlatın ve kendi alan adınızsa MX kaydını kontrol edin. |
429 | O adres için saniyede birden fazla okuma ya da bu runner'dan dakikada 1200'den fazla istek. | Retry-After saniyesi kadar uyuyun ve devam edin. Bir 429 üzerine testi asla başarısız saymayın. |
Kodu ya da bağlantıyı mesajdan çıkarmak
Mesaj her iki parçayla birlikte döner ve hangisini ayrıştıracağınız uygulamanızın ne gönderdiğine bağlıdır:
text- Düz metin parçası. Var olduğunda bunu ayrıştırın — biçimlendirme yoktur ve altı haneli bir kod, altı haneli bir koddur.
html- HTML parçası, ya da gönderen yalnızca metin gönderdiyse
null. Onay bağlantıları genellikle yalnızca burada, bir<a href>içinde bulunur ve&,&olarak yazılır.
import type { Message } from './fixtures';
/** The whole body, both parts, with HTML entities a link may carry undone. */
const bodyOf = (m: Message) => `${m.text ?? ''}\n${m.html ?? ''}`.replace(/&/g, '&');
/** Anchored on your own wording, so a reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\s*([0-9]{6})/i): string {
const hit = bodyOf(m).match(pattern);
if (!hit) throw new Error(`no confirmation code in "${m.subject}"`);
return hit[1];
}
/** The link whose path contains a fragment you know — never "the first URL". */
export function linkFrom(m: Message, pathContains: string): string {
const re = new RegExp(`https?://[^\\s"'<>]*${pathContains}[^\\s"'<>]*`);
const hit = bodyOf(m).match(re);
if (!hit) throw new Error(`no link containing "${pathContains}" in "${m.subject}"`);
return hit[0];
}Desen, bilinçli olarak kendi şablonunuzun ifadesine sabitlenmiştir. Tek başına [0-9]{6}, ilk sırada görünen bir yılı, bir fiyatı ya da bir sipariş numarasını mutlulukla eşleştirir; code is ([0-9]{6}) ise yalnızca kodunuzla eşleşir, başka hiçbir şeyle değil — ve biri e-postayı yeniden yazdığı gün, test yanlış sayı üzerinden geçmek yerine başarısız olur ve size haber verir.
Bağlantılar, "ilk URL" üzerinden değil, bildiğiniz bir yol parçası — /confirm/, /reset/ — üzerinden eşleştirilir, çünkü işlemsel bir e-posta genellikle beşini birden taşır: logo, abonelikten çıkma, yardım merkezi, uygulama mağazası rozeti ve istediğiniz olan.
Üç akış, uçtan uca
Fixture ve extractor'lar yerli yerindeyken, her test test ettiği özellik gibi okunur. Bekleme, yoklama ve ayrıştırma başka bir yerdedir — onları oraya koymanın tüm amacı da budur.
Onay koduyla kayıt
import { test, expect } from './fixtures';
import { codeFrom } from './extract';
test('a new account confirms its email address', async ({ page, inbox }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(inbox.address);
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByText('Check your inbox')).toBeVisible();
const message = await inbox.waitFor({ subjectContains: 'confirm' });
await page.getByLabel('Confirmation code').fill(codeFrom(message));
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});Kullanıcıyı oturum açtıran bir magic link
Yazacak hiçbir şey yok: test, mesajın taşıdığı bağlantıyı ziyaret eder ve nereye indiğini doğrular.
import { test, expect } from './fixtures';
import { linkFrom } from './extract';
test('a magic link signs the user in', async ({ page, inbox }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Email me a link' }).click();
const message = await inbox.waitFor({ subjectContains: 'sign in' });
await page.goto(linkFrom(message, '/auth/magic/'));
await expect(page).toHaveURL(/\/dashboard/);
});Bir parola sıfırlama, ardından yeni parolayla giriş
Sıfırlama testi, zaten var olan bir kullanıcıya ihtiyaç duyar; bu da tarayıcının değil, uygulamanızın kendi test arayüzünün işidir — dahili bir endpoint, bir veritabanı fixture'ı, bir CLI. Ardından akış, diğerleriyle aynı şekle sahiptir: istek, bekleme, takip, doğrulama.
import { test, expect } from './fixtures';
import { linkFrom } from './extract';
test('a password reset link changes the password', async ({ page, inbox, request }) => {
// Your application's own test seam: an internal endpoint, a DB fixture, a CLI.
await request.post('/internal/test/users', { data: { email: inbox.address, password: 'old-password-1' } });
await page.goto('/forgot-password');
await page.getByLabel('Email').fill(inbox.address);
await page.getByRole('button', { name: 'Send reset link' }).click();
const message = await inbox.waitFor({ subjectContains: 'reset' });
await page.goto(linkFrom(message, '/reset/'));
await page.getByLabel('New password').fill('new-password-2');
await page.getByRole('button', { name: 'Change password' }).click();
await page.goto('/login');
await page.getByLabel('Email').fill(inbox.address);
await page.getByLabel('Password').fill('new-password-2');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
});Bunu CI'da hayatta tutmak
Yukarıdakilerin hepsi bir dizüstü bilgisayarda çalışır. Bunlar, yalnızca başkasının makinesinde günde yirmi kez çalıştığında bozulan şeylerdir.
| Belirti | Neden | Çözüm |
|---|---|---|
| Yerelde geçiyor, CI'da başarısız oluyor | Runner genel internete ulaşamıyor ya da giden trafik filtreleniyor. | HTTPS üzerinden grabmail.io adresine izin verin. Başka hiçbir şeye gerek yok — SMTP portu yok, gelen trafik yok. |
| İlk seferde başarısız, yeniden denemede geçiyor | Göndereniniz postayı kuyruğa alıyor ve son tarih kuyruktan daha kısa. | Başka bir şeye dokunmadan önce son tarihi yükseltin. Altmış saniye, işlemsel bir posta için makul bir üst sınırdır. |
Patlamalar halinde 429 | Birkaç testin tek bir adresi yoklaması ya da tüm runner'ın dakikada 1200 isteği aşması. | Test başına bir adres — fixture bunu zaten yapar. İstemci üst sınırı, saniyede bir kez yoklanan yirmi posta kutusudur. |
| Yeşil build, bozuk özellik | Yeniden kullanılan bir adres eski bir mesajı sundu. | Test başına rastgele bir adres. Önemli olan budur. |
| Yalnızca birkaç worker'la kararsız | Bir posta kutusunu paylaşan iki test ya da hangi mesajın en yeni olduğuna dair bir doğrulama. | Test başına yeni bir adres ve bir subjectContains filtresi; en yeni mesajı asla körü körüne almayın. |
| Bir hafta çalışıyor, sonra hiç çalışmıyor | Bir mesaj kimliğini önbelleğe alan bir fixture; buradaki her şey 5 gün sonra silinir. | Testler her çalıştırmada kendi postalarını tetiklemelidir. Hiçbir şey 5 günden fazla hayatta kalmaz. |
Saklanacak bir secret yoktur. Genel alan adları anahtar, hesap ya da başlık gerektirmez — pipeline'ınızın bu testleri çalıştırmak için bir kimlik bilgisine ihtiyacı varsa, bir şey yanlış anlaşılmış demektir. Yazmaya değer tek ayar zaman aşımıdır, çünkü postayı bekleyen bir test için Playwright'ın varsayılanlarının yanlış yaptığı tek şey budur:
// playwright.config.ts — the project that reads mail gets a timeout above the mail deadline
export default defineConfig({
timeout: 120_000,
expect: { timeout: 10_000 },
fullyParallel: true, // safe: every test has its own inbox
});Son tarih ve giden trafik meselesi çözülmüş halde bu paketi çalıştıran bir GitHub Actions workflow'u, CI rehberinde yazılıdır.
Uygulamanız tek kullanımlık alan adlarını reddediyorsa
Bazı kayıt formları, adresi tek kullanımlık alan adlarının genel listelerine karşı kontrol eder ve grabmail.io alan adını görür görmez reddeder. Bu, uygulamanızın bir özelliğidir, testin bir kusuru değildir — ve çözüm, kontrolü test ortamı için zayıflatmak değildir. Bunun yerine sahibi olduğunuz bir alan adını bu hizmete yönlendirin: tek bir MX kaydı, hesap yok, ve üzerindeki her adres, tek bir sabiti değiştirerek aynı fixture'ın okuyabileceği bir posta kutusu haline gelir.
Bir alan adını catch-all bir gelen kutusuna dönüştürmek kurulumdur; tek bir alan adında sınırsız test hesabı ise bunun bir test paketinde nasıl göründüğüdür.
Bitti demeden önce
- Fixture'dan, her test için farklı bir adres — asla bir sabit değil.
- Gerçek zamanlı bir son tarih ve beklediği adresi belirten bir başarısızlık mesajı.
429, başarısız sayılarak değil,Retry-Aftersüresince uyuyarak ele alınır.- Kod ya da bağlantı, yalın bir desen değil, kendi ifadenize karşı eşleştirilir.
- Posta son tarihinin rahatça üzerinde bir test zaman aşımı.
- Konu ya da gönderen üzerinde bir filtre, böylece iki mesaj geldiğinde doğru olan kazanır.
- Postanın ne kadar hızlı geldiğine dair bir doğrulama yok — yalnızca geldiğine dair.
Disiplinin tamamı bu kadar. Playwright'ta postayı test etmeyle ilgili geri kalan her şey, asenkron olan başka herhangi bir şeyi test etmekle aynıdır. Cypress komutları olarak aynı yardımcı, Cypress rehberinde; herhangi bir test çalıştırıcısı için tek başına ayıklama kuralları ise otomatik testlerde OTP kodları rehberinde.
Sorular
Playwright'tan posta kutusunu okumak için bir API anahtarına ihtiyacım var mı?
Hayır. Genel alan adları anahtar, hesap ya da başlık gerektirmez. Yalnızca tek kullanımlık posta kara listelerinin dışında tutulan ücretli alan adı havuzu bir Authorization: Bearer başlığı gerektirir ve bu ayrı bir üründür.
Testler paralel worker'larda çalışabilir mi?
Evet, ve test başına rastgele bir adresin amacı da budur: iki worker birbirinin postasını hiçbir zaman okuyamaz. İstemci başına üst sınır dakikada 1200 istektir, bu da saniyede bir kez yoklanan yirmi posta kutusu demektir — bir paket için fazlasıyla yeterli, ve fixture zaten saniyede birden daha hızlı yoklamaz.
Fetch yerine Playwright'ın request fixture'ını mı kullanmalıyım?
İkisi de çalışır. Burada fetch kullanılıyor, çünkü böylece yardımcı fonksiyon düz bir Node betiğinde, bir global setup'ta ya da başka bir test çalıştırıcısında değişmeden çalışır. Playwright'ın request'i çağrılara izleme ekler; yoklamanın trace viewer'da görünmesini istiyorsanız buna sahip olmak değerlidir.
Ya e-posta, test yoklamaya başlamadan önce gelirse?
Hiçbir şey değişmez. İlk yoklama onu döndürür. Bir posta kutusu, kimse okusun ya da okumasın, gelen her şeyi 5 gün boyunca tutar; bu yüzden tıklama sırasında düşen bir mesaj, bir sonraki istekte orada bulunur.
Test onu kullanırken posta kutusu özel midir?
Hayır. Genel bir alan adında da kendi alan adınızda da, adresi bilen herkes onu okuyabilir. On bir saniye var olan ve tek bir kullan-at kod tutan rastgele bir adres için bu önemsizdir; gerçek müşteri postası gönderen bir staging ortamı için ise bu diskalifiye edici bir durumdur — böyle birini buraya yönlendirmeyin.
Sonrasında nasıl temizlik yaparım?
İsteğe bağlı olarak, idempotent olan mesaj üzerinde bir DELETE ile. Zaten her şeyin süresi 5 gün sonra doluyor, bu yüzden temizliği atlayan bir çalıştırmanın hiçbir bedeli yoktur — silmek yalnızca bir sonraki başarısızlığı okumayı kolaylaştırır.


