Test ve CI

Playwright ile e-posta testi: gerçek bir kutudan kod okuma

Bir Playwright testi, bir kayıt formunu iki saniyede doldurabilir ama ardından ne olduğu hakkında hiçbir fikri yoktur, çünkü sıradaki adım bir e-postadır. İşte onu okuyan fixture — API anahtarı olmadan, gerçek bir posta kutusundan gelen gerçek bir mesaj — ve testin kararsızlaşmasını önleyen üç alışkanlık.

  • Orta düzey
  • 20 dk okuma
Mavi imleç oklu gri bir tarayıcı penceresi, yan tarafındaki yarığa kayan mavi bir zarf ve önünde gri bir kronometre

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.

tests/fixtures.ts
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ı 429 ve bir Retry-After başlığıyla reddedilir; daha hızlı yoklamak postayı daha erken getirmez.
  • waitForTimeout yok. 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. subjectContains ya da fromContains geç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:

KodAnlamıDöngünün yaptığı
200Posta 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.
400Adres hatalı biçimlendirilmiştir.Hata fırlatın. Bir yazım hatasını yeniden denemek onu düzeltmez.
404Alan adı burada barındırılmıyor.Hata fırlatın ve kendi alan adınızsa MX kaydını kontrol edin.
429O 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 &, &amp; olarak yazılır.
tests/extract.ts
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(/&amp;/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

tests/signup.spec.ts
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.

tests/magic-link.spec.ts
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.

tests/password-reset.spec.ts
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.

BelirtiNedenÇözüm
Yerelde geçiyor, CI'da başarısız oluyorRunner 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çiyorGö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 429Birkaç 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 özellikYeniden kullanılan bir adres eski bir mesajı sundu.Test başına rastgele bir adres. Önemli olan budur.
Yalnızca birkaç worker'la kararsızBir 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ıyorBir 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
// 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-After sü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.

Henüz tazeyken deneyin

Bir adres tek tıkla alınır, hesap ve kart gerekmez. Bu rehberdeki her şey onunla hemen çalışır.

Tekrar hoş geldiniz

Kutularınız ve alan adlarınız tek bir yerde.